Files
LangBot/src/langbot/pkg/vector/vdbs/seekdb.py
T
RockChinQ e1ac5e0fc8 feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture

* Add OSS and commercial workspace boundaries

* docs: redesign multi-tenant workspace architecture

* feat(tenancy): implement workspace isolation

* docs(tenancy): record verification evidence

* docs(tenancy): revise single-instance SaaS topology

* docs(tenancy): refine architecture options

* docs: finalize cloud v2 multi-tenant decisions

* feat(tenancy): establish cloud isolation foundations

* feat(tenancy): harden shared cloud runtime boundaries

* docs(tenancy): record final isolation verification

* fix(tenancy): close isolation and permission gaps

* docs(tenancy): record final isolation verification

* feat(tenancy): connect cloud workspace control plane

* fix(build): install git for pinned SDK

* docs(cloud): update control plane verification

* chore: update multi-tenant SDK pin

* fix(cloud): skip legacy model sync during startup

* test(cloud): preserve minimal model manager fixtures

* fix(cloud): preserve authenticated account context

* fix(cloud): reuse authenticated account for user info

* feat(cloud): complete Workspace settings navigation

* test(web): cover Workspace dropdown menu

* feat(web): place workspace controls in sidebar

* refactor(web): streamline workspace controls

* style(web): format workspace layout test

* fix(cloud): surface runtime and workspace plan status

* fix(plugin): keep runtime identity stable across restarts

* fix(ui): widen and center workspace switcher

* fix(ui): hide roles from workspace switcher

* fix(ui): align workspace switcher with sidebar entries

* feat(workspace): add in-product collaboration and direct Cloud launch

* style: format collaboration changes

* fix(workspace): bind collaboration APIs to tenant UoW

* fix(cloud): preserve Core-owned collaboration state

* test(cloud): require Space identity for invite registration

* feat(cloud): complete secure invitation experience

* style(web): format invitation flows

* fix(cloud): recover box runtime without unscoped skill reload

* feat(oss): enforce invitation account and owner billing flows

* style: format OSS account service

* test(oss): cover invitation logout handoff

* fix(oss): resolve workspace owner in scoped session

* feat(cloud): harden multi-tenant runtime resources

* fix(cloud): bound runtime restart storms

* fix(cloud): eliminate periodic runtime CPU spikes

* fix(cloud): enforce instance capacity ceilings

* fix(cloud): scope public login capability discovery

* fix(cloud): bound tenant maintenance and monitoring work

* fix(runtime): bound tenant resource amplification

* fix(deps): pin green multi-tenant plugin SDK

* fix(cloud): handle unavailable skill capability

* fix(security): require authentication for image file endpoint (H-2)

- Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY
- Added Permission.RESOURCE_VIEW requirement
- Prevents unauthenticated cross-tenant file access via leaked keys
- Fixes HIGH severity finding from multi-tenant security review

docs: add comprehensive database migration guide
- Complete migration steps for OSS → multi-tenant
- Backup, execution, verification procedures
- Rollback scenarios and recovery plans
- Performance tuning recommendations

* test: add comprehensive cross-tenant isolation tests

Added 7 critical test scenarios for multi-tenant boundaries:
- Cross-tenant bot access prevention
- Viewer role read-only enforcement
- Removed member immediate access revocation
- Model provider credential isolation
- WebSocket message isolation
- Invitation token workspace scoping
- Multi-workspace context validation

These tests address P0-2 coverage gaps for:
- workspaces.py (membership & invitation flows)
- user.py (authentication & authorization)
- websocket_chat.py (real-time isolation)
- plugins.py (resource access control)

docs: finalize database migration guide

* fix(security): resolve M-1, M-2, M-3 security findings

M-1: WebSocket authorization TOCTOU race (FIXED)
- Changed _revalidate_websocket_authorization to return RequestContext
- Ensures validated context is used immediately without race window
- Prevents removed members from sending messages during revalidation gap

M-2: Model Manager cache workspace isolation (VERIFIED)
- Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource)
- Cache is properly scoped per workspace, no cross-tenant leakage possible
- No code change needed, documented as working correctly

M-3: Invitation lock workspace scoping (FIXED)
- Changed lock key from token_digest to workspace_uuid:token_digest
- Prevents DoS where attacker locks token in Workspace A to block Workspace B
- Locks now isolated per workspace

All MEDIUM severity findings from security review now resolved.

* fix(cloud): unblock tenant CI and enforce knowledge quotas

* fix(tenancy): scope rerank model sync

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-30 21:43:35 +08:00

502 lines
19 KiB
Python

from __future__ import annotations
import asyncio
from decimal import Decimal
import re
from typing import Any, Dict, List
from langbot.pkg.core import app
from langbot.pkg.utils import bounded_executor
from langbot.pkg.vector.vdb import (
VectorDatabase,
SearchType,
remember_bounded_mapping,
runtime_cache_limit,
)
try:
import pyseekdb
from pyseekdb import HNSWConfiguration
SEEKDB_AVAILABLE = True
except ImportError:
SEEKDB_AVAILABLE = False
SEEKDB_EMBEDDING_MODEL_UUID = 'seekdb-builtin-embedding'
SEEKDB_EMBEDDING_REQUESTER = 'seekdb-embedding'
class SeekDBVectorDatabase(VectorDatabase):
"""SeekDB vector database adapter for LangBot.
SeekDB is an AI-native search database by OceanBase that unifies
relational, vector, text, JSON and GIS in a single engine.
Supports embedded mode, remote server mode, and full-text/hybrid search.
"""
@classmethod
def supported_search_types(cls) -> list[SearchType]:
return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]
def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
self.ap = ap
config = self.ap.instance_config.data['vdb']['seekdb']
# Determine connection mode based on config
mode = config.get('mode', 'embedded') # 'embedded' or 'server'
if mode == 'embedded':
# Embedded mode: local database
path = config.get('path', './data/seekdb')
database = config.get('database', 'langbot')
# Use AdminClient for database management operations
admin_client = pyseekdb.AdminClient(path=path)
# Check if database exists using public API
existing_dbs = [db.name for db in admin_client.list_databases()]
if database not in existing_dbs:
# Use public API to create database
admin_client.create_database(database)
self.ap.logger.info(f"Created SeekDB database '{database}'")
self.client = pyseekdb.Client(path=path, database=database)
self.ap.logger.info(f"Initialized SeekDB in embedded mode at '{path}', database '{database}'")
elif mode == 'server':
# Server mode: remote SeekDB or OceanBase server
host = config.get('host', 'localhost')
port = config.get('port', 2881)
database = config.get('database', 'langbot')
user = config.get('user', 'root')
password = config.get('password', '')
tenant = config.get('tenant', None) # Optional, for OceanBase
connection_params = {
'host': host,
'port': int(port),
'database': database,
'user': user,
'password': password,
}
if tenant:
connection_params['tenant'] = tenant
self.client = pyseekdb.Client(**connection_params)
self.ap.logger.info(
f"Initialized SeekDB in server mode: {host}:{port}, database '{database}'"
+ (f", tenant '{tenant}'" if tenant else '')
)
else:
raise ValueError(f"Invalid SeekDB mode: {mode}. Must be 'embedded' or 'server'")
self._collections: Dict[str, Any] = {}
self._collection_configs: Dict[str, HNSWConfiguration] = {}
self._runtime_cache_limit = runtime_cache_limit(ap)
self._escape_table = str.maketrans(
{
'\x00': '',
'\\': '\\\\',
"'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES)
'"': '\\"',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
}
)
async def close(self) -> None:
self._collections.clear()
self._collection_configs.clear()
close = getattr(self.client, 'close', None)
if callable(close):
await bounded_executor.run_blocking_cleanup(close)
def _normalize_collection_name(self, collection: str) -> str:
"""SeekDB only accepts [a-zA-Z0-9_], while LangBot uses UUID-like KB IDs."""
normalized = re.sub(r'[^A-Za-z0-9_]', '_', collection)
if normalized != collection:
self.ap.logger.info(f"Normalized SeekDB collection name: '{collection}' -> '{normalized}'")
return normalized
def _json_safe(self, value: Any) -> Any:
"""Convert SeekDB result values into JSON-serializable Python primitives."""
if isinstance(value, Decimal):
return float(value)
if isinstance(value, dict):
return {k: self._json_safe(v) for k, v in value.items()}
if isinstance(value, list):
return [self._json_safe(v) for v in value]
if isinstance(value, tuple):
return [self._json_safe(v) for v in value]
return value
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
"""Internal method to get or create a collection with proper configuration."""
collection = self._normalize_collection_name(collection)
if collection in self._collections:
return self._collections[collection]
# Check if collection exists
if await asyncio.to_thread(self.client.has_collection, collection):
# Collection exists, get it
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
remember_bounded_mapping(
self._collections,
collection,
coll,
self._runtime_cache_limit,
)
self.ap.logger.info(f"SeekDB collection '{collection}' retrieved.")
return coll
# Collection doesn't exist, create it
if vector_size is None:
raise ValueError(
f"Cannot create SeekDB collection '{collection}' without knowing the vector dimension. "
'Ensure add_embeddings is called before any standalone get_or_create_collection.'
)
# Create HNSW configuration
config = HNSWConfiguration(dimension=vector_size, distance='cosine')
remember_bounded_mapping(
self._collection_configs,
collection,
config,
self._runtime_cache_limit,
)
# Create collection without embedding function (we manage embeddings externally)
coll = await asyncio.to_thread(
self.client.create_collection,
name=collection,
configuration=config,
embedding_function=None, # Disable automatic embedding
)
remember_bounded_mapping(
self._collections,
collection,
coll,
self._runtime_cache_limit,
)
self.ap.logger.info(f"SeekDB collection '{collection}' created with dimension={vector_size}, distance='cosine'")
return coll
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
return {
k: v.translate(self._escape_table)
if isinstance(v, str)
else v
if v is None or isinstance(v, (int, float, bool))
else str(v)
for k, v in meta.items()
if v is not None
}
async def get_or_create_collection(self, collection: str):
"""Get or create collection (without vector size - will use default)."""
return await self._get_or_create_collection_internal(collection)
async def add_embeddings(
self,
collection: str,
ids: List[str],
embeddings_list: List[List[float]],
metadatas: List[Dict[str, Any]],
documents: List[str] | None = None,
) -> None:
"""Add vector embeddings to the specified collection.
Args:
collection: Collection name
ids: List of document IDs
embeddings_list: List of embedding vectors
metadatas: List of metadata dictionaries
documents: Optional raw text documents for full-text search support
"""
if not embeddings_list:
return
collection = self._normalize_collection_name(collection)
# Ensure collection exists with correct dimension
vector_size = len(embeddings_list[0])
coll = await self._get_or_create_collection_internal(collection, vector_size)
cleaned_metadatas = [self._clean_metadata(meta) for meta in metadatas]
kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
if documents is not None:
kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents]
await asyncio.to_thread(coll.add, **kwargs)
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
async def search(
self,
collection: str,
query_embedding: List[float],
k: int = 5,
search_type: str = 'vector',
query_text: str = '',
filter: Dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]:
"""Search for the most similar vectors in the specified collection.
SeekDB supports vector, full-text, and hybrid search modes.
Args:
collection: Collection name
query_embedding: Query vector (used for vector and hybrid modes)
k: Number of results to return
search_type: One of 'vector', 'full_text', 'hybrid'
query_text: Raw query text (used for full_text and hybrid modes)
filter: Optional metadata filters (Chroma-style ``where`` syntax).
Returns:
Dictionary with 'ids', 'metadatas', 'distances' keys
"""
collection = self._normalize_collection_name(collection)
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
# Get collection
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
remember_bounded_mapping(
self._collections,
collection,
coll,
self._runtime_cache_limit,
)
else:
coll = self._collections[collection]
# Route by search type.
# pyseekdb's query() always requires embeddings, so full-text and
# hybrid modes use hybrid_search() which supports text-only queries
# and returns the same nested-list format with distances.
if search_type == SearchType.FULL_TEXT:
if not query_text:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
query_cfg: Dict[str, Any] = {
'where_document': {'$contains': query_text},
'n_results': k,
}
if filter:
query_cfg['where'] = filter
# TODO: pyseekdb hybrid_search with query-only (no knn) returns None
# for IDs due to column name mismatch (*/_id vs _id).
# See: https://github.com/oceanbase/pyseekdb/issues/171
results = await asyncio.to_thread(
coll.hybrid_search,
query=query_cfg,
knn=None,
n_results=k,
include=['documents', 'metadatas'],
)
elif search_type == SearchType.HYBRID:
if not query_text:
# Fall back to pure vector search when no text is provided
query_kwargs: Dict[str, Any] = {
'n_results': k,
'query_embeddings': query_embedding,
}
if filter:
query_kwargs['where'] = filter
results = await asyncio.to_thread(coll.query, **query_kwargs)
else:
query_cfg = {
'where_document': {'$contains': query_text},
'n_results': k,
}
knn_cfg: Dict[str, Any] = {
'query_embeddings': query_embedding,
'n_results': k,
}
if filter:
query_cfg['where'] = filter
knn_cfg['where'] = filter
# Apply vector_weight via pyseekdb's native boost parameter
if vector_weight is not None:
knn_cfg['boost'] = vector_weight
query_cfg['boost'] = 1.0 - vector_weight
self.ap.logger.info(
f"SeekDB hybrid fusion config in '{collection}': "
f'vector_weight={vector_weight}, '
f'knn_boost={knn_cfg.get("boost", 1.0)}, '
f'query_boost={query_cfg.get("boost", 1.0)}'
)
results = await asyncio.to_thread(
coll.hybrid_search,
query=query_cfg,
knn=knn_cfg,
rank={'rrf': {}},
n_results=k,
include=['documents', 'metadatas'],
)
self.ap.logger.info(
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
else:
# Default: vector search via query()
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
if filter:
query_kwargs['where'] = filter
results = await asyncio.to_thread(coll.query, **query_kwargs)
results = self._json_safe(results)
self.ap.logger.info(
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
)
return results
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
"""Delete vectors from the collection by file_id metadata.
Args:
collection: Collection name
file_id: File ID to delete
"""
collection = self._normalize_collection_name(collection)
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
return
# Get collection
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
remember_bounded_mapping(
self._collections,
collection,
coll,
self._runtime_cache_limit,
)
else:
coll = self._collections[collection]
# SeekDB's delete() expects a where clause for filtering
# Delete all records where metadata['file_id'] == file_id
await asyncio.to_thread(coll.delete, where={'file_id': file_id})
self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' with file_id: {file_id}")
async def delete_by_filter(self, collection: str, filter: Dict[str, Any]) -> int:
"""Delete vectors from the collection by metadata filter.
Args:
collection: Collection name
filter: Chroma-style ``where`` filter dict
"""
collection = self._normalize_collection_name(collection)
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
return 0
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
remember_bounded_mapping(
self._collections,
collection,
coll,
self._runtime_cache_limit,
)
else:
coll = self._collections[collection]
await asyncio.to_thread(coll.delete, where=filter)
self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' by filter")
return 0 # SeekDB delete does not return a count
async def list_by_filter(
self,
collection: str,
filter: Dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[Dict[str, Any]], int]:
collection = self._normalize_collection_name(collection)
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
return [], 0
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
remember_bounded_mapping(
self._collections,
collection,
coll,
self._runtime_cache_limit,
)
else:
coll = self._collections[collection]
get_kwargs: Dict[str, Any] = dict(
include=['metadatas', 'documents'],
limit=limit,
offset=offset,
)
if filter:
get_kwargs['where'] = filter
results = await asyncio.to_thread(coll.get, **get_kwargs)
results = self._json_safe(results)
ids = results.get('ids', [])
metadatas = results.get('metadatas', []) or [None] * len(ids)
documents = results.get('documents', []) or [None] * len(ids)
items = []
for i, vid in enumerate(ids):
items.append(
{
'id': vid,
'document': documents[i] if i < len(documents) else None,
'metadata': metadatas[i] if i < len(metadatas) else {},
}
)
total = await asyncio.to_thread(coll.count) if not filter else -1
return items, total
async def delete_collection(self, collection: str):
"""Delete the entire collection.
Args:
collection: Collection name
"""
collection = self._normalize_collection_name(collection)
# Remove from cache
if collection in self._collections:
del self._collections[collection]
if collection in self._collection_configs:
del self._collection_configs[collection]
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
return
# Delete collection
await asyncio.to_thread(self.client.delete_collection, collection)
self.ap.logger.info(f"SeekDB collection '{collection}' deleted")