Files
LangBot/src/langbot/pkg/pipeline/pool.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

429 lines
18 KiB
Python

from __future__ import annotations
import asyncio
import dataclasses
import inspect
import typing
import uuid
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from ..api.http.context import ExecutionContext
from . import plugin_diagnostics
QueryCacheKey = tuple[str, str]
LegacyQueryKey = tuple[str, int]
QueryCounterKey = tuple[str, str, int]
SingletonContextResolver = typing.Callable[
[],
ExecutionContext | typing.Awaitable[ExecutionContext],
]
class ExecutionContextRequiredError(ValueError):
"""Raised when runtime work is created without a trusted Workspace scope."""
class ExecutionContextMismatchError(ValueError):
"""Raised when entity fields conflict with their trusted execution scope."""
class QueryNotFoundError(LookupError):
"""Raised when a query does not exist inside the requested Workspace."""
class QueryPoolCapacityError(RuntimeError):
"""Raised when no queued query can be discarded to admit new work."""
def _validate_execution_context(execution_context: ExecutionContext) -> None:
if not isinstance(execution_context, ExecutionContext):
raise ExecutionContextRequiredError('A trusted ExecutionContext is required')
if not isinstance(execution_context.instance_uuid, str) or not execution_context.instance_uuid.strip():
raise ExecutionContextRequiredError('ExecutionContext.instance_uuid is required')
if not isinstance(execution_context.workspace_uuid, str) or not execution_context.workspace_uuid.strip():
raise ExecutionContextRequiredError('ExecutionContext.workspace_uuid is required')
if (
isinstance(execution_context.placement_generation, bool)
or not isinstance(execution_context.placement_generation, int)
or execution_context.placement_generation <= 0
):
raise ExecutionContextRequiredError('ExecutionContext.placement_generation must be a positive integer')
for field_name in ('bot_uuid', 'pipeline_uuid', 'query_uuid'):
value = getattr(execution_context, field_name)
if value is not None and (not isinstance(value, str) or not value.strip()):
raise ExecutionContextRequiredError(f'ExecutionContext.{field_name} must be a non-empty string when set')
def bind_execution_context(
execution_context: ExecutionContext,
*,
bot_uuid: str | None = None,
pipeline_uuid: str | None = None,
query_uuid: str | None = None,
) -> ExecutionContext:
"""Bind runtime entity identifiers without allowing scope substitution."""
_validate_execution_context(execution_context)
requested_fields = {
'bot_uuid': bot_uuid,
'pipeline_uuid': pipeline_uuid,
'query_uuid': query_uuid,
}
updates: dict[str, str] = {}
for field_name, requested_value in requested_fields.items():
if requested_value is None:
continue
if not isinstance(requested_value, str) or not requested_value.strip():
raise ExecutionContextRequiredError(f'{field_name} must be a non-empty string')
current_value = getattr(execution_context, field_name)
if current_value is not None and current_value != requested_value:
raise ExecutionContextMismatchError(f'ExecutionContext.{field_name} does not match the runtime entity')
if current_value is None:
updates[field_name] = requested_value
if not updates:
return execution_context
return dataclasses.replace(execution_context, **updates)
def get_query_execution_context(query: pipeline_query.Query) -> ExecutionContext:
"""Return and validate the trusted context attached to a Query."""
attached_context = getattr(query, '_execution_context', None)
bot_uuid = getattr(query, 'bot_uuid', None)
pipeline_uuid = getattr(query, 'pipeline_uuid', None)
query_uuid = getattr(query, 'query_uuid', None)
if isinstance(attached_context, ExecutionContext):
return bind_execution_context(
attached_context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
)
raise ExecutionContextRequiredError('Query is missing its trusted ExecutionContext')
class QueryPool:
"""Workspace-scoped queue of requests waiting for pipeline scheduling."""
query_id_counter: int
pool_lock: asyncio.Lock
queries: list[pipeline_query.Query]
cached_queries: dict[QueryCacheKey, pipeline_query.Query]
legacy_query_index: dict[LegacyQueryKey, str]
query_count_by_scope: dict[QueryCounterKey, int]
condition: asyncio.Condition
def __init__(
self,
singleton_context_resolver: SingletonContextResolver | None = None,
*,
max_queries: int = 1000,
max_queries_per_workspace: int = 100,
):
if max_queries < 1:
raise ValueError('max_queries must be positive')
if max_queries_per_workspace < 1:
raise ValueError('max_queries_per_workspace must be positive')
if max_queries_per_workspace > max_queries:
raise ValueError('max_queries_per_workspace cannot exceed max_queries')
self.query_id_counter = 0
self.pool_lock = asyncio.Lock()
self.queries = []
self.cached_queries = {}
self.active_query_count_by_workspace: dict[str, int] = {}
self.legacy_query_index = {}
self.query_count_by_scope = {}
self.dropped_query_count_by_scope: dict[QueryCounterKey, int] = {}
self.condition = asyncio.Condition(self.pool_lock)
self._singleton_context_resolver = singleton_context_resolver
self.max_queries = max_queries
self.max_queries_per_workspace = max_queries_per_workspace
def _discard_queued_query_locked(
self,
*,
workspace_uuid: str | None = None,
) -> pipeline_query.Query | None:
"""Discard the oldest queued query from one scope and all indexes."""
for index, query in enumerate(self.queries):
execution_context = get_query_execution_context(query)
if workspace_uuid is not None and execution_context.workspace_uuid != workspace_uuid:
continue
self.queries.pop(index)
query_uuid = execution_context.query_uuid
if query_uuid is not None:
self.cached_queries.pop((execution_context.workspace_uuid, query_uuid), None)
self.legacy_query_index.pop((execution_context.workspace_uuid, query.query_id), None)
query_workspace_uuid = execution_context.workspace_uuid
remaining = self.active_query_count_by_workspace.get(query_workspace_uuid, 0) - 1
if remaining > 0:
self.active_query_count_by_workspace[query_workspace_uuid] = remaining
else:
self.active_query_count_by_workspace.pop(query_workspace_uuid, None)
plugin_diagnostics.discard_query_state(query)
counter_key = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
)
self.dropped_query_count_by_scope[counter_key] = self.dropped_query_count_by_scope.get(counter_key, 0) + 1
return query
return None
def _admit_query_locked(self, workspace_uuid: str) -> None:
workspace_query_count = self.active_query_count_by_workspace.get(workspace_uuid, 0)
if workspace_query_count >= self.max_queries_per_workspace:
if self._discard_queued_query_locked(workspace_uuid=workspace_uuid) is None:
raise QueryPoolCapacityError(f'Workspace query capacity reached ({self.max_queries_per_workspace})')
if len(self.cached_queries) >= self.max_queries:
if self._discard_queued_query_locked() is None:
raise QueryPoolCapacityError(f'Global query capacity reached ({self.max_queries})')
def mark_query_running_locked(self, query: pipeline_query.Query) -> None:
"""Remove a scheduled query from the overload-discardable queue."""
if not self.pool_lock.locked():
raise RuntimeError('Query pool lock is required to schedule a query')
for index, queued_query in enumerate(self.queries):
if queued_query is query:
self.queries.pop(index)
return
raise QueryNotFoundError('Scheduled query is no longer queued')
def _make_scope_counter_room_locked(self, counter_key: QueryCounterKey) -> None:
"""Retain recent counters without pinning every historical Workspace."""
if counter_key in self.query_count_by_scope:
return
while len(self.query_count_by_scope) >= self.max_queries:
stale_key = next(
(
existing_key
for existing_key in self.query_count_by_scope
if self.active_query_count_by_workspace.get(existing_key[1], 0) <= 0
),
None,
)
if stale_key is None:
raise QueryPoolCapacityError('Query counter capacity reached while every Workspace is active')
self.query_count_by_scope.pop(stale_key, None)
self.dropped_query_count_by_scope.pop(stale_key, None)
async def resolve_execution_context(
self,
execution_context: ExecutionContext | None,
*,
bot_uuid: str,
pipeline_uuid: str | None,
query_uuid: str | None = None,
) -> ExecutionContext:
"""Resolve an explicit scope or the opt-in OSS singleton scope."""
if execution_context is None:
if self._singleton_context_resolver is None:
raise ExecutionContextRequiredError('ExecutionContext is required; no singleton resolver is configured')
resolved_context = self._singleton_context_resolver()
if inspect.isawaitable(resolved_context):
resolved_context = await resolved_context
execution_context = resolved_context
return bind_execution_context(
execution_context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
)
async def add_query(
self,
bot_uuid: str,
launcher_type: provider_session.LauncherTypes,
launcher_id: int | str,
sender_id: int | str,
message_event: platform_events.MessageEvent,
message_chain: platform_message.MessageChain,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: str | None = None,
routed_by_rule: bool = False,
variables: dict[str, typing.Any] | None = None,
execution_context: ExecutionContext | None = None,
) -> pipeline_query.Query:
"""Create a query and cache it under an opaque, Workspace-scoped key."""
query_uuid = str(uuid.uuid4())
execution_context = await self.resolve_execution_context(
execution_context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
)
async with self.condition:
self._admit_query_locked(execution_context.workspace_uuid)
query_id = self.query_id_counter
initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule}
if variables:
initial_variables.update(variables)
query = pipeline_query.Query(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
bot_uuid=bot_uuid,
query_id=query_id,
query_uuid=query_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_id,
message_event=message_event,
message_chain=message_chain,
variables=initial_variables,
resp_messages=[],
resp_message_chain=[],
adapter=adapter,
pipeline_uuid=pipeline_uuid,
)
# langbot-plugin 0.4.13 ignores these forward-compatible fields.
# Attach them explicitly until the Workspace-aware SDK is released.
object.__setattr__(query, 'instance_uuid', execution_context.instance_uuid)
object.__setattr__(query, 'workspace_uuid', execution_context.workspace_uuid)
object.__setattr__(
query,
'placement_generation',
execution_context.placement_generation,
)
object.__setattr__(query, 'query_uuid', query_uuid)
object.__setattr__(query, '_execution_context', execution_context)
self.queries.append(query)
self.cached_queries[(execution_context.workspace_uuid, query_uuid)] = query
self.active_query_count_by_workspace[execution_context.workspace_uuid] = (
self.active_query_count_by_workspace.get(execution_context.workspace_uuid, 0) + 1
)
self.legacy_query_index[(execution_context.workspace_uuid, query_id)] = query_uuid
self.query_id_counter += 1
counter_key = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
)
# A Workspace has only one active placement. Drop obsolete
# generation counters so deployment churn cannot grow these maps.
for existing_key in tuple(self.query_count_by_scope):
if existing_key[:2] == counter_key[:2] and existing_key != counter_key:
self.query_count_by_scope.pop(existing_key, None)
self.dropped_query_count_by_scope.pop(existing_key, None)
self._make_scope_counter_room_locked(counter_key)
self.query_count_by_scope[counter_key] = self.query_count_by_scope.get(counter_key, 0) + 1
self.condition.notify_all()
return query
def get_query_count(self, execution_context: ExecutionContext) -> int:
"""Return the lifetime query count for one active placement scope."""
_validate_execution_context(execution_context)
return self.query_count_by_scope.get(
(
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
),
0,
)
def get_dropped_query_count(self, execution_context: ExecutionContext) -> int:
"""Return overload drops for one active placement scope."""
_validate_execution_context(execution_context)
return self.dropped_query_count_by_scope.get(
(
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
),
0,
)
async def get_query(
self,
workspace_uuid: str,
query_uuid: str,
) -> pipeline_query.Query | None:
"""Return a query only from the explicitly selected Workspace."""
async with self.pool_lock:
return self.cached_queries.get((workspace_uuid, query_uuid))
async def require_query(
self,
workspace_uuid: str,
query_uuid: str,
) -> pipeline_query.Query:
"""Return a scoped query or raise without checking other Workspaces."""
query = await self.get_query(workspace_uuid, query_uuid)
if query is None:
raise QueryNotFoundError(f'Query {query_uuid!r} was not found in Workspace {workspace_uuid!r}')
return query
async def get_query_by_legacy_id(
self,
workspace_uuid: str,
query_id: int,
) -> pipeline_query.Query | None:
"""Resolve a legacy integer ID within one explicit Workspace."""
async with self.pool_lock:
query_uuid = self.legacy_query_index.get((workspace_uuid, query_id))
if query_uuid is None:
return None
return self.cached_queries.get((workspace_uuid, query_uuid))
async def remove_query(self, query: pipeline_query.Query) -> bool:
"""Remove a query and both of its Workspace-scoped indexes."""
execution_context = get_query_execution_context(query)
query_uuid = execution_context.query_uuid
if query_uuid is None:
raise ExecutionContextRequiredError('Query.query_uuid is required for removal')
async with self.pool_lock:
cache_key = (execution_context.workspace_uuid, query_uuid)
cached_query = self.cached_queries.get(cache_key)
if cached_query is not query:
return False
del self.cached_queries[cache_key]
remaining = self.active_query_count_by_workspace.get(execution_context.workspace_uuid, 0) - 1
if remaining > 0:
self.active_query_count_by_workspace[execution_context.workspace_uuid] = remaining
else:
self.active_query_count_by_workspace.pop(execution_context.workspace_uuid, None)
self.legacy_query_index.pop(
(execution_context.workspace_uuid, query.query_id),
None,
)
for index, queued_query in enumerate(self.queries):
if queued_query is query:
self.queries.pop(index)
break
plugin_diagnostics.discard_query_state(query)
return True
async def __aenter__(self):
await self.pool_lock.acquire()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
self.pool_lock.release()