mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-21 20:06:06 +00:00
feat(tenancy): establish cloud isolation foundations
This commit is contained in:
@@ -69,6 +69,7 @@ class ExecutionContext:
|
||||
pipeline_uuid: str | None = None
|
||||
query_uuid: str | None = None
|
||||
trigger_principal: PrincipalContext | None = None
|
||||
entitlement_revision: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_request(
|
||||
@@ -89,4 +90,5 @@ class ExecutionContext:
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
query_uuid=query_uuid,
|
||||
trigger_principal=ctx.principal,
|
||||
entitlement_revision=ctx.entitlement_revision,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from quart.typing import RouteCallable
|
||||
from ....utils import constants
|
||||
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ....cloud.entitlements import EntitlementUnavailableError
|
||||
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
|
||||
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
|
||||
@@ -244,6 +245,10 @@ class RouterGroup(abc.ABC):
|
||||
|
||||
requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
|
||||
access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
|
||||
entitlement_revision = await self._resolve_entitlement_revision(
|
||||
access.execution.instance_uuid,
|
||||
access.workspace.uuid,
|
||||
)
|
||||
request_context = RequestContext(
|
||||
instance_uuid=access.execution.instance_uuid,
|
||||
placement_generation=access.execution.placement_generation,
|
||||
@@ -260,6 +265,7 @@ class RouterGroup(abc.ABC):
|
||||
permissions=permissions_for_role(access.membership.role),
|
||||
membership_revision=access.membership.projection_revision,
|
||||
),
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
quart.g.request_context = request_context
|
||||
quart.g.workspace_membership = access.membership
|
||||
@@ -272,6 +278,10 @@ class RouterGroup(abc.ABC):
|
||||
if inspect.isawaitable(authenticated):
|
||||
identity = await authenticated
|
||||
if identity is not None:
|
||||
entitlement_revision = await self._resolve_entitlement_revision(
|
||||
identity.instance_uuid,
|
||||
identity.workspace_uuid,
|
||||
)
|
||||
request_context = RequestContext(
|
||||
instance_uuid=identity.instance_uuid,
|
||||
placement_generation=identity.placement_generation,
|
||||
@@ -287,6 +297,7 @@ class RouterGroup(abc.ABC):
|
||||
role=None,
|
||||
permissions=identity.permissions,
|
||||
),
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
quart.g.request_context = request_context
|
||||
return request_context
|
||||
@@ -316,6 +327,18 @@ class RouterGroup(abc.ABC):
|
||||
quart.g.request_context = request_context
|
||||
return request_context
|
||||
|
||||
async def _resolve_entitlement_revision(self, instance_uuid: str, workspace_uuid: str) -> int:
|
||||
deployment = getattr(self.ap, 'deployment', None)
|
||||
if deployment is None or not getattr(deployment, 'multi_workspace_enabled', False):
|
||||
return 0
|
||||
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
||||
if resolver is None:
|
||||
raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable')
|
||||
if instance_uuid != resolver.instance_uuid:
|
||||
raise EntitlementUnavailableError('Workspace entitlement targets another LangBot instance')
|
||||
snapshot = await resolver.resolve(workspace_uuid)
|
||||
return snapshot.entitlement_revision
|
||||
|
||||
@staticmethod
|
||||
def _inject_handler_context(
|
||||
handler: RouteCallable,
|
||||
@@ -339,6 +362,8 @@ class RouterGroup(abc.ABC):
|
||||
return self.http_status(404, 'resource_not_found', 'Resource not found')
|
||||
if isinstance(error, MembershipPermissionError):
|
||||
return self.http_status(403, error.code, str(error))
|
||||
if isinstance(error, EntitlementUnavailableError):
|
||||
return self.http_status(403, 'entitlement_unavailable', str(error))
|
||||
request_id = self.request_id()
|
||||
logger = getattr(self.ap, 'logger', self.quart_app.logger)
|
||||
logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}')
|
||||
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import unquote
|
||||
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError
|
||||
from ... import group
|
||||
|
||||
|
||||
@@ -31,6 +32,8 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
data = await quart.request.json
|
||||
try:
|
||||
server_uuid = await self.ap.mcp_service.create_mcp_server(request_context, data)
|
||||
except MCPStdioDisabledError as exc:
|
||||
return self.http_status(403, exc.code, str(exc))
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'uuid': server_uuid})
|
||||
@@ -63,6 +66,8 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
data = await quart.request.json
|
||||
try:
|
||||
await self.ap.mcp_service.update_mcp_server(request_context, server_data['uuid'], data)
|
||||
except MCPStdioDisabledError as exc:
|
||||
return self.http_status(403, exc.code, str(exc))
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
else:
|
||||
@@ -79,11 +84,14 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
"""测试MCP服务器连接"""
|
||||
server_name = unquote(server_name)
|
||||
server_data = await quart.request.json
|
||||
task_id = await self.ap.mcp_service.test_mcp_server(
|
||||
request_context,
|
||||
server_name=server_name,
|
||||
server_data=server_data,
|
||||
)
|
||||
try:
|
||||
task_id = await self.ap.mcp_service.test_mcp_server(
|
||||
request_context,
|
||||
server_name=server_name,
|
||||
server_data=server_data,
|
||||
)
|
||||
except MCPStdioDisabledError as exc:
|
||||
return self.http_status(403, exc.code, str(exc))
|
||||
return self.success(data={'task_id': task_id})
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -8,6 +8,7 @@ from .....utils import constants
|
||||
from .....entity.persistence.metadata import WorkspaceMetadata
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
|
||||
|
||||
|
||||
@group.group_class('system', '/api/v1/system')
|
||||
@@ -70,6 +71,9 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'disable_models_service': self.ap.instance_config.data.get('space', {}).get(
|
||||
'disable_models_service', False
|
||||
),
|
||||
# Exposed independently of Box status so the WebUI cannot
|
||||
# infer stdio permission from sandbox availability.
|
||||
'mcp_stdio_enabled': stdio_mcp_enabled(self.ap),
|
||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||
'outbound_ips': outbound_ips,
|
||||
'wizard_status': wizard_status,
|
||||
|
||||
@@ -11,6 +11,7 @@ from ....core import app, taskmgr
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
from ....entity.persistence import plugin as persistence_plugin
|
||||
from ....provider.tools.loaders.mcp import MCPSessionStatus, RuntimeMCPSession
|
||||
from ....provider.tools.loaders.mcp_policy import require_stdio_mcp_enabled
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ..context import ExecutionContext
|
||||
from .secrets import is_url_key, redact_url_secrets, restore_url_secret_placeholders
|
||||
@@ -207,6 +208,10 @@ class MCPService:
|
||||
execution_context = await self._execution_context(context)
|
||||
workspace_uuid = execution_context.workspace_uuid
|
||||
|
||||
# This gate is independent of Box availability. Cloud v2 disables
|
||||
# stdio MCP even though Box Runtime itself remains available.
|
||||
require_stdio_mcp_enabled(self.ap, server_data)
|
||||
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
max_extensions = limitation.get('max_extensions', -1)
|
||||
if max_extensions >= 0:
|
||||
@@ -315,6 +320,13 @@ class MCPService:
|
||||
if duplicate is not None and duplicate['uuid'] != server_uuid:
|
||||
raise ValueError(f'MCP server already exists: {payload["name"]}')
|
||||
|
||||
effective_server = {**old_server, **payload}
|
||||
# Existing disabled rows remain readable/deletable. Switching away
|
||||
# from stdio or explicitly disabling one is also allowed, but an
|
||||
# update may never leave a disabled stdio server enabled.
|
||||
if bool(effective_server.get('enable', True)):
|
||||
require_stdio_mcp_enabled(self.ap, effective_server)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_mcp.MCPServer)
|
||||
@@ -405,7 +417,8 @@ class MCPService:
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
|
||||
if server_name != '_':
|
||||
await self._require_server(execution_context, server_name)
|
||||
_, persisted_server = await self._require_server(execution_context, server_name)
|
||||
require_stdio_mcp_enabled(self.ap, persisted_server)
|
||||
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
|
||||
if runtime_mcp_session is None:
|
||||
raise WorkspaceNotFoundError('MCP server not found')
|
||||
@@ -427,6 +440,7 @@ class MCPService:
|
||||
payload = dict(server_data)
|
||||
payload.pop('workspace_uuid', None)
|
||||
payload['workspace_uuid'] = execution_context.workspace_uuid
|
||||
require_stdio_mcp_enabled(self.ap, payload)
|
||||
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(
|
||||
execution_context,
|
||||
payload,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Contracts used by the optional closed Cloud control-plane bootstrap."""
|
||||
|
||||
from .bootstrap import (
|
||||
CloudBootstrapError,
|
||||
OpenSourceDeployment,
|
||||
VerifiedCloudDeployment,
|
||||
resolve_deployment,
|
||||
)
|
||||
from .entitlements import (
|
||||
EntitlementProvider,
|
||||
EntitlementResolver,
|
||||
EntitlementSnapshot,
|
||||
EntitlementUnavailableError,
|
||||
OpenSourceEntitlementProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'CloudBootstrapError',
|
||||
'EntitlementProvider',
|
||||
'EntitlementResolver',
|
||||
'EntitlementSnapshot',
|
||||
'EntitlementUnavailableError',
|
||||
'OpenSourceDeployment',
|
||||
'OpenSourceEntitlementProvider',
|
||||
'VerifiedCloudDeployment',
|
||||
'resolve_deployment',
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
||||
|
||||
|
||||
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
||||
REQUIRED_TENANT_ISOLATION_VERSION = 2
|
||||
|
||||
|
||||
class CloudBootstrapError(RuntimeError):
|
||||
"""Fail-closed Cloud bootstrap validation error."""
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class OpenSourceDeployment:
|
||||
"""Default deployment selected when no closed bootstrap is installed."""
|
||||
|
||||
mode: str = 'oss'
|
||||
workspace_policy: SingleWorkspacePolicy = dataclasses.field(default_factory=SingleWorkspacePolicy)
|
||||
entitlement_provider: OpenSourceEntitlementProvider = dataclasses.field(
|
||||
default_factory=OpenSourceEntitlementProvider
|
||||
)
|
||||
persistence_mode: str = 'oss_compat'
|
||||
required_vector_backend: str | None = None
|
||||
|
||||
@property
|
||||
def multi_workspace_enabled(self) -> bool:
|
||||
return False
|
||||
|
||||
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
||||
del config
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class VerifiedCloudDeployment:
|
||||
"""Receipt returned only after the closed package verifies a Manifest.
|
||||
|
||||
Core deliberately does not accept a config flag as a substitute for this
|
||||
object. The closed entry point owns root-key/JWS verification and the
|
||||
entitlement adapter; open Core validates the receipt's runtime invariants.
|
||||
"""
|
||||
|
||||
instance_uuid: str
|
||||
manifest_jti: str
|
||||
manifest_generation: int
|
||||
expires_at: int
|
||||
release: str
|
||||
capabilities: frozenset[str]
|
||||
tenant_isolation_version: int
|
||||
entitlement_provider: EntitlementProvider
|
||||
verification_key_id: str
|
||||
mode: str = dataclasses.field(default='cloud', init=False)
|
||||
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
|
||||
persistence_mode: str = dataclasses.field(default='cloud_runtime', init=False)
|
||||
required_vector_backend: str = dataclasses.field(default='pgvector', init=False)
|
||||
|
||||
@property
|
||||
def multi_workspace_enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
def validate(self, expected_instance_uuid: str, *, now: int | None = None) -> None:
|
||||
current_time = int(time.time()) if now is None else now
|
||||
if not self.instance_uuid or self.instance_uuid != expected_instance_uuid:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest targets another LangBot instance')
|
||||
if not self.manifest_jti or not self.verification_key_id:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest receipt is incomplete')
|
||||
if isinstance(self.manifest_generation, bool) or self.manifest_generation <= 0:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest generation must be positive')
|
||||
if self.expires_at <= current_time:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest is expired')
|
||||
if self.tenant_isolation_version < REQUIRED_TENANT_ISOLATION_VERSION:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest requires an unsupported tenant isolation version')
|
||||
if 'multi_workspace_v2' not in self.capabilities:
|
||||
raise CloudBootstrapError('Verified Cloud Manifest does not grant multi_workspace_v2')
|
||||
if not isinstance(self.entitlement_provider, EntitlementProvider):
|
||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide an entitlement adapter')
|
||||
|
||||
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
||||
if config.get('database', {}).get('use') != 'postgresql':
|
||||
raise CloudBootstrapError('Cloud runtime requires database.use=postgresql')
|
||||
if config.get('vdb', {}).get('use') != self.required_vector_backend:
|
||||
raise CloudBootstrapError('Cloud runtime requires vdb.use=pgvector')
|
||||
if config.get('mcp', {}).get('stdio', {}).get('enabled', True) is not False:
|
||||
raise CloudBootstrapError('Cloud runtime requires mcp.stdio.enabled=false')
|
||||
|
||||
|
||||
class CloudBootstrapProvider(Protocol):
|
||||
def bootstrap(
|
||||
self,
|
||||
*,
|
||||
instance_uuid: str,
|
||||
instance_config: dict[str, Any],
|
||||
) -> VerifiedCloudDeployment | Awaitable[VerifiedCloudDeployment]: ...
|
||||
|
||||
|
||||
async def _invoke_provider(
|
||||
loaded: Any,
|
||||
*,
|
||||
instance_uuid: str,
|
||||
instance_config: dict[str, Any],
|
||||
) -> VerifiedCloudDeployment:
|
||||
provider = loaded() if inspect.isclass(loaded) else loaded
|
||||
bootstrap = getattr(provider, 'bootstrap', None)
|
||||
if not callable(bootstrap):
|
||||
raise CloudBootstrapError('Cloud bootstrap entry point must expose bootstrap()')
|
||||
result = bootstrap(instance_uuid=instance_uuid, instance_config=instance_config)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if not isinstance(result, VerifiedCloudDeployment):
|
||||
raise CloudBootstrapError('Cloud bootstrap must return VerifiedCloudDeployment')
|
||||
return result
|
||||
|
||||
|
||||
async def resolve_deployment(
|
||||
*,
|
||||
instance_uuid: str,
|
||||
instance_config: dict[str, Any],
|
||||
entry_points: Callable[[], Any] | None = None,
|
||||
now: int | None = None,
|
||||
) -> OpenSourceDeployment | VerifiedCloudDeployment:
|
||||
"""Discover the optional closed bootstrap and validate its receipt.
|
||||
|
||||
Absence selects OSS singleton mode. Presence is fail-closed: duplicate,
|
||||
broken, invalid, or expired providers never fall back to an OSS Workspace.
|
||||
"""
|
||||
|
||||
discover = entry_points or importlib.metadata.entry_points
|
||||
discovered = discover()
|
||||
if hasattr(discovered, 'select'):
|
||||
candidates = list(discovered.select(group=CLOUD_BOOTSTRAP_ENTRY_POINT))
|
||||
else: # Python/importlib compatibility for dict-like EntryPoints
|
||||
candidates = list(discovered.get(CLOUD_BOOTSTRAP_ENTRY_POINT, ()))
|
||||
if not candidates:
|
||||
deployment = OpenSourceDeployment()
|
||||
deployment.validate_instance_config(instance_config)
|
||||
return deployment
|
||||
if len(candidates) != 1:
|
||||
raise CloudBootstrapError('Exactly one Cloud bootstrap provider may be installed')
|
||||
|
||||
try:
|
||||
loaded = candidates[0].load()
|
||||
deployment = await _invoke_provider(
|
||||
loaded,
|
||||
instance_uuid=instance_uuid,
|
||||
instance_config=instance_config,
|
||||
)
|
||||
deployment.validate(instance_uuid, now=now)
|
||||
deployment.validate_instance_config(instance_config)
|
||||
return deployment
|
||||
except CloudBootstrapError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise CloudBootstrapError('Closed Cloud bootstrap failed') from exc
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import pydantic
|
||||
|
||||
|
||||
class EntitlementUnavailableError(RuntimeError):
|
||||
"""Raised when a trusted, currently-active entitlement is unavailable."""
|
||||
|
||||
|
||||
class EntitlementSnapshot(pydantic.BaseModel):
|
||||
"""Plan-agnostic capability projection consumed by open-source Core.
|
||||
|
||||
Product and billing names deliberately do not appear here. The closed
|
||||
Control Plane maps subscriptions to these generic features and limits.
|
||||
"""
|
||||
|
||||
model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
|
||||
|
||||
instance_uuid: str = pydantic.Field(min_length=1, max_length=256)
|
||||
workspace_uuid: str = pydantic.Field(min_length=1, max_length=256)
|
||||
entitlement_revision: int = pydantic.Field(ge=1)
|
||||
status: str = pydantic.Field(pattern=r'^(active|suspended|cancelled)$')
|
||||
not_before: int = pydantic.Field(ge=0)
|
||||
expires_at: int = pydantic.Field(gt=0)
|
||||
features: dict[str, bool] = pydantic.Field(default_factory=dict)
|
||||
limits: dict[str, int] = pydantic.Field(default_factory=dict)
|
||||
|
||||
@pydantic.field_validator('features')
|
||||
@classmethod
|
||||
def _validate_feature_names(cls, value: dict[str, bool]) -> dict[str, bool]:
|
||||
if any(not str(name).strip() for name in value):
|
||||
raise ValueError('Entitlement feature names must be non-empty')
|
||||
return dict(value)
|
||||
|
||||
@pydantic.field_validator('limits')
|
||||
@classmethod
|
||||
def _validate_limits(cls, value: dict[str, int]) -> dict[str, int]:
|
||||
normalized: dict[str, int] = {}
|
||||
for name, limit in value.items():
|
||||
if not str(name).strip():
|
||||
raise ValueError('Entitlement limit names must be non-empty')
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
|
||||
raise ValueError(f'Entitlement limit {name!r} must be a non-negative integer')
|
||||
normalized[str(name)] = limit
|
||||
return normalized
|
||||
|
||||
def require_active(
|
||||
self,
|
||||
*,
|
||||
instance_uuid: str,
|
||||
workspace_uuid: str,
|
||||
now: int | None = None,
|
||||
) -> EntitlementSnapshot:
|
||||
current_time = int(time.time()) if now is None else now
|
||||
if self.instance_uuid != instance_uuid or self.workspace_uuid != workspace_uuid:
|
||||
raise EntitlementUnavailableError('Entitlement scope does not match the Workspace execution context')
|
||||
if self.status != 'active':
|
||||
raise EntitlementUnavailableError('Workspace entitlement is not active')
|
||||
if current_time < self.not_before or current_time >= self.expires_at:
|
||||
raise EntitlementUnavailableError('Workspace entitlement is not currently valid')
|
||||
return self
|
||||
|
||||
def require_feature(self, feature: str) -> None:
|
||||
if self.features.get(feature) is not True:
|
||||
raise EntitlementUnavailableError(f'Workspace entitlement does not grant {feature}')
|
||||
|
||||
def limit(self, name: str) -> int:
|
||||
value = self.limits.get(name)
|
||||
if value is None:
|
||||
raise EntitlementUnavailableError(f'Workspace entitlement does not define limit {name}')
|
||||
return value
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EntitlementProvider(Protocol):
|
||||
"""Closed Control Plane adapter injected by a verified Cloud bootstrap."""
|
||||
|
||||
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
|
||||
"""Return the newest verified snapshot for one Workspace."""
|
||||
|
||||
|
||||
class OpenSourceEntitlementProvider:
|
||||
"""Marker provider for OSS; Cloud admission grants never use this class."""
|
||||
|
||||
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
|
||||
del workspace_uuid
|
||||
raise EntitlementUnavailableError('Signed Workspace entitlements are only available in Cloud mode')
|
||||
|
||||
|
||||
class EntitlementResolver:
|
||||
"""Validate scope/freshness and reject revision rollback or equivocation."""
|
||||
|
||||
def __init__(self, instance_uuid: str, provider: EntitlementProvider) -> None:
|
||||
self.instance_uuid = instance_uuid
|
||||
self.provider = provider
|
||||
self._lock = asyncio.Lock()
|
||||
self._snapshots: dict[str, tuple[int, str, EntitlementSnapshot]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(snapshot: EntitlementSnapshot) -> str:
|
||||
return json.dumps(snapshot.model_dump(mode='json'), sort_keys=True, separators=(',', ':'))
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
workspace_uuid: str,
|
||||
*,
|
||||
minimum_revision: int = 0,
|
||||
now: int | None = None,
|
||||
) -> EntitlementSnapshot:
|
||||
candidate = await self.provider.get_workspace_entitlement(workspace_uuid)
|
||||
if not isinstance(candidate, EntitlementSnapshot):
|
||||
raise EntitlementUnavailableError('Entitlement provider returned an invalid snapshot')
|
||||
# Deep-copy untrusted provider-owned containers before caching them.
|
||||
candidate = EntitlementSnapshot.model_validate(candidate.model_dump())
|
||||
candidate.require_active(
|
||||
instance_uuid=self.instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
now=now,
|
||||
)
|
||||
if candidate.entitlement_revision < minimum_revision:
|
||||
raise EntitlementUnavailableError('Workspace entitlement revision rolled back')
|
||||
|
||||
fingerprint = self._fingerprint(candidate)
|
||||
async with self._lock:
|
||||
previous = self._snapshots.get(workspace_uuid)
|
||||
if previous is not None:
|
||||
previous_revision, previous_fingerprint, _ = previous
|
||||
if candidate.entitlement_revision < previous_revision:
|
||||
raise EntitlementUnavailableError('Workspace entitlement revision rolled back')
|
||||
if candidate.entitlement_revision == previous_revision and fingerprint != previous_fingerprint:
|
||||
raise EntitlementUnavailableError('Workspace entitlement revision has conflicting contents')
|
||||
self._snapshots[workspace_uuid] = (
|
||||
candidate.entitlement_revision,
|
||||
fingerprint,
|
||||
candidate,
|
||||
)
|
||||
return candidate.model_copy(deep=True)
|
||||
@@ -48,6 +48,8 @@ from ..survey import manager as survey_module
|
||||
from ..skill import manager as skill_mgr
|
||||
from ..workspace import service as workspace_service_module
|
||||
from ..workspace import collaboration as workspace_collaboration_module
|
||||
from ..cloud import bootstrap as cloud_bootstrap_module
|
||||
from ..cloud import entitlements as cloud_entitlements_module
|
||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from ..entity.persistence.workspace import WorkspaceExecutionState, WorkspaceExecutionStatus
|
||||
|
||||
@@ -128,6 +130,10 @@ class Application:
|
||||
|
||||
workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None
|
||||
|
||||
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
||||
|
||||
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
|
||||
|
||||
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
||||
|
||||
http_ctrl: http_controller.HTTPController = None
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
|
||||
from .. import stage, app
|
||||
from ...utils import version, proxy
|
||||
from ...utils import version, proxy, constants
|
||||
from ...pipeline import pool, controller, pipelinemgr
|
||||
from ...pipeline import aggregator as message_aggregator
|
||||
from ...box import service as box_service
|
||||
@@ -41,7 +41,8 @@ from ...telemetry import telemetry as telemetry_module
|
||||
from ...survey import manager as survey_module
|
||||
from ...workspace import service as workspace_service_module
|
||||
from ...workspace import collaboration as workspace_collaboration_module
|
||||
from ...workspace import policy as workspace_policy_module
|
||||
from ...cloud import bootstrap as cloud_bootstrap
|
||||
from ...cloud.entitlements import EntitlementResolver
|
||||
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from ...api.http.authz import WorkspaceRequiredError
|
||||
|
||||
@@ -52,6 +53,20 @@ class BuildAppStage(stage.BootingStage):
|
||||
|
||||
async def run(self, ap: app.Application):
|
||||
"""Build LangBot application"""
|
||||
# Multi-Workspace mode is selected only by an installed closed
|
||||
# bootstrap that returns a verified Manifest receipt. Mutable values
|
||||
# such as system.edition are intentionally absent from this boundary.
|
||||
deployment = await cloud_bootstrap.resolve_deployment(
|
||||
instance_uuid=constants.instance_id,
|
||||
instance_config=ap.instance_config.data,
|
||||
)
|
||||
ap.deployment = deployment
|
||||
ap.entitlement_resolver = (
|
||||
EntitlementResolver(constants.instance_id, deployment.entitlement_provider)
|
||||
if deployment.multi_workspace_enabled
|
||||
else None
|
||||
)
|
||||
|
||||
ap.task_mgr = taskmgr.AsyncTaskManager(ap)
|
||||
|
||||
discover = discover_engine.ComponentDiscoveryEngine(ap)
|
||||
@@ -109,16 +124,14 @@ class BuildAppStage(stage.BootingStage):
|
||||
await storage_mgr_inst.initialize()
|
||||
ap.storage_mgr = storage_mgr_inst
|
||||
|
||||
persistence_mgr_inst = persistencemgr.PersistenceManager(ap)
|
||||
persistence_mgr_inst = persistencemgr.PersistenceManager(
|
||||
ap,
|
||||
mode=persistencemgr.PersistenceMode(deployment.persistence_mode),
|
||||
)
|
||||
ap.persistence_mgr = persistence_mgr_inst
|
||||
await persistence_mgr_inst.initialize()
|
||||
|
||||
# The open-source Core is intentionally single-Workspace. A mutable
|
||||
# config value such as ``system.edition`` is product metadata, not a
|
||||
# trust credential, and must never activate SaaS routing. The closed
|
||||
# Cloud bootstrap will install its verified policy only after checking
|
||||
# a signed InstanceManifest; until that bootstrap exists, fail closed.
|
||||
workspace_policy = workspace_policy_module.open_core_workspace_policy()
|
||||
workspace_policy = deployment.workspace_policy
|
||||
workspace_service_inst = workspace_service_module.WorkspaceService(
|
||||
ap,
|
||||
policy=workspace_policy,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import copy
|
||||
from typing import Any
|
||||
from langbot.pkg.utils import constants
|
||||
import yaml
|
||||
@@ -12,6 +13,44 @@ from .. import stage, app
|
||||
from ..bootutils import config
|
||||
|
||||
|
||||
_RUNTIME_POLICY_DEFAULTS = {
|
||||
'plugin': {
|
||||
'worker': {
|
||||
'max_cpus': 1.0,
|
||||
'max_memory_mb': 512,
|
||||
'max_pids': 128,
|
||||
'max_open_files': 256,
|
||||
'max_file_size_mb': 512,
|
||||
}
|
||||
},
|
||||
'mcp': {'stdio': {'enabled': True}},
|
||||
}
|
||||
|
||||
|
||||
def _complete_runtime_policy_defaults(cfg: dict) -> dict:
|
||||
"""Backfill typed security-policy leaves before applying env overrides.
|
||||
|
||||
The historic config loader intentionally does not deep-complete the whole
|
||||
template. These fields are different: their native env overrides must
|
||||
retain boolean/numeric types on upgraded instances, so their defaults must
|
||||
exist before ``PLUGIN__...`` and ``MCP__...`` are parsed.
|
||||
"""
|
||||
|
||||
def merge(target: dict, defaults: dict, path: tuple[str, ...] = ()) -> None:
|
||||
for key, default in defaults.items():
|
||||
if key not in target:
|
||||
target[key] = copy.deepcopy(default)
|
||||
continue
|
||||
if isinstance(default, dict):
|
||||
if not isinstance(target[key], dict):
|
||||
dotted_path = '.'.join((*path, key))
|
||||
raise ValueError(f'{dotted_path} must be a mapping')
|
||||
merge(target[key], default, (*path, key))
|
||||
|
||||
merge(cfg, _RUNTIME_POLICY_DEFAULTS)
|
||||
return cfg
|
||||
|
||||
|
||||
def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||
"""Apply environment variable overrides to data/config.yaml
|
||||
|
||||
@@ -150,6 +189,10 @@ class LoadConfigStage(stage.BootingStage):
|
||||
|
||||
ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False)
|
||||
|
||||
# Deep-complete only typed execution-policy fields. This keeps native
|
||||
# env coercion reliable for existing data/config.yaml files.
|
||||
ap.instance_config.data = _complete_runtime_policy_defaults(ap.instance_config.data)
|
||||
|
||||
# Apply environment variable overrides to data/config.yaml
|
||||
ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data)
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""enforce PostgreSQL tenant isolation with row-level security
|
||||
|
||||
Revision ID: 0011_postgres_tenant_rls
|
||||
Revises: 0010_scope_resources
|
||||
Create Date: 2026-07-19
|
||||
|
||||
The table list is deliberately duplicated from the runtime contract. Alembic
|
||||
revisions must remain self-contained after application code evolves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0011_postgres_tenant_rls'
|
||||
down_revision = '0010_scope_resources'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_POLICY_NAME = 'langbot_workspace_isolation'
|
||||
_TENANT_SETTING = 'langbot.workspace_uuid'
|
||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
||||
_TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'workspaces': 'uuid',
|
||||
'workspace_memberships': 'workspace_uuid',
|
||||
'workspace_invitations': 'workspace_uuid',
|
||||
'workspace_execution_states': 'workspace_uuid',
|
||||
'workspace_metadata': 'workspace_uuid',
|
||||
'api_keys': 'workspace_uuid',
|
||||
'bots': 'workspace_uuid',
|
||||
'bot_admins': 'workspace_uuid',
|
||||
'binary_storages': 'workspace_uuid',
|
||||
'mcp_servers': 'workspace_uuid',
|
||||
'model_providers': 'workspace_uuid',
|
||||
'llm_models': 'workspace_uuid',
|
||||
'embedding_models': 'workspace_uuid',
|
||||
'rerank_models': 'workspace_uuid',
|
||||
'legacy_pipelines': 'workspace_uuid',
|
||||
'pipeline_run_records': 'workspace_uuid',
|
||||
'plugin_settings': 'workspace_uuid',
|
||||
'knowledge_bases': 'workspace_uuid',
|
||||
'knowledge_base_files': 'workspace_uuid',
|
||||
'knowledge_base_chunks': 'workspace_uuid',
|
||||
'webhooks': 'workspace_uuid',
|
||||
'monitoring_messages': 'workspace_uuid',
|
||||
'monitoring_llm_calls': 'workspace_uuid',
|
||||
'monitoring_tool_calls': 'workspace_uuid',
|
||||
'monitoring_sessions': 'workspace_uuid',
|
||||
'monitoring_errors': 'workspace_uuid',
|
||||
'monitoring_embedding_calls': 'workspace_uuid',
|
||||
'monitoring_feedback': 'workspace_uuid',
|
||||
}
|
||||
|
||||
|
||||
def _quote_identifier(conn: sa.Connection, identifier: str) -> str:
|
||||
return conn.dialect.identifier_preparer.quote(identifier)
|
||||
|
||||
|
||||
def _record_oss_workspace_scope(conn: sa.Connection) -> None:
|
||||
"""Keep PostgreSQL OSS usable after FORCE RLS is enabled.
|
||||
|
||||
The runtime reads this instance-level metadata and installs the singleton
|
||||
Workspace as a transaction-local default. Cloud databases have no local
|
||||
Workspace and therefore do not receive this compatibility value.
|
||||
"""
|
||||
local_workspaces = (
|
||||
conn.execute(sa.text("SELECT uuid FROM workspaces WHERE source = 'local' ORDER BY uuid")).scalars().all()
|
||||
)
|
||||
if len(local_workspaces) != 1:
|
||||
return
|
||||
|
||||
existing = conn.execute(
|
||||
sa.text('SELECT value FROM metadata WHERE key = :key'),
|
||||
{'key': _OSS_WORKSPACE_METADATA_KEY},
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
sa.text('INSERT INTO metadata (key, value) VALUES (:key, :value)'),
|
||||
{'key': _OSS_WORKSPACE_METADATA_KEY, 'value': local_workspaces[0]},
|
||||
)
|
||||
elif existing != local_workspaces[0]:
|
||||
raise RuntimeError('Stored OSS Workspace scope does not match the local Workspace')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return
|
||||
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
missing_tables = set(_TENANT_TABLE_COLUMNS) - existing_tables
|
||||
if missing_tables:
|
||||
raise RuntimeError(f'Cannot enable tenant RLS before all tenant-owned tables exist: {sorted(missing_tables)!r}')
|
||||
|
||||
_record_oss_workspace_scope(conn)
|
||||
|
||||
policy_name = _quote_identifier(conn, _POLICY_NAME)
|
||||
for table_name, tenant_column in _TENANT_TABLE_COLUMNS.items():
|
||||
table = _quote_identifier(conn, table_name)
|
||||
column = _quote_identifier(conn, tenant_column)
|
||||
tenant_value = f"CAST(NULLIF(current_setting('{_TENANT_SETTING}', true), '') AS VARCHAR(36))"
|
||||
|
||||
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy_name} ON {table}'))
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE POLICY {policy_name} ON {table} '
|
||||
f'USING ({column} = {tenant_value}) '
|
||||
f'WITH CHECK ({column} = {tenant_value})'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return
|
||||
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
policy_name = _quote_identifier(conn, _POLICY_NAME)
|
||||
for table_name in _TENANT_TABLE_COLUMNS:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
table = _quote_identifier(conn, table_name)
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy_name} ON {table}'))
|
||||
op.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
|
||||
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
@@ -59,6 +60,16 @@ def _do_get_current(connection: Connection) -> str | None:
|
||||
return ctx.get_current_revision()
|
||||
|
||||
|
||||
def get_alembic_head() -> str:
|
||||
"""Resolve the single release head without opening a database connection."""
|
||||
cfg = Config()
|
||||
cfg.set_main_option('script_location', _ALEMBIC_DIR)
|
||||
head = ScriptDirectory.from_config(cfg).get_current_head()
|
||||
if head is None:
|
||||
raise RuntimeError('Alembic has no migration head')
|
||||
return head
|
||||
|
||||
|
||||
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
|
||||
"""Synchronous autogenerate — runs inside run_sync."""
|
||||
cfg = _build_config(connection)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
import sqlite3
|
||||
import typing
|
||||
|
||||
@@ -15,6 +16,7 @@ from ..entity import persistence
|
||||
from ..core import app
|
||||
from ..utils import constants, importutil
|
||||
from . import databases, migrations
|
||||
from .tenant_uow import TENANT_POLICY_NAME, TENANT_SETTING, TENANT_TABLE_COLUMNS, TenantUnitOfWork
|
||||
|
||||
importutil.import_modules_in_pkg(databases)
|
||||
importutil.import_modules_in_pkg(migrations)
|
||||
@@ -64,6 +66,15 @@ _PRE_WORKSPACE_ALEMBIC_REVISIONS = {
|
||||
}
|
||||
_WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
|
||||
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
|
||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
||||
|
||||
|
||||
class PersistenceMode(enum.StrEnum):
|
||||
"""Trusted persistence startup mode selected by the process entrypoint."""
|
||||
|
||||
OSS_COMPAT = 'oss_compat'
|
||||
CLOUD_RUNTIME = 'cloud_runtime'
|
||||
RELEASE_MIGRATION = 'release_migration'
|
||||
|
||||
|
||||
class PersistenceManager:
|
||||
@@ -76,9 +87,12 @@ class PersistenceManager:
|
||||
|
||||
meta: sqlalchemy.MetaData
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
def __init__(self, ap: app.Application, *, mode: PersistenceMode = PersistenceMode.OSS_COMPAT):
|
||||
if not isinstance(mode, PersistenceMode):
|
||||
raise TypeError('PersistenceManager mode must be a trusted PersistenceMode value')
|
||||
self.ap = ap
|
||||
self.meta = base.Base.metadata
|
||||
self.mode = mode
|
||||
|
||||
async def initialize(self):
|
||||
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||
@@ -89,7 +103,27 @@ class PersistenceManager:
|
||||
await self.db.initialize()
|
||||
break
|
||||
|
||||
engine = self.get_db_engine()
|
||||
if self.mode in {PersistenceMode.CLOUD_RUNTIME, PersistenceMode.RELEASE_MIGRATION}:
|
||||
if engine.dialect.name != 'postgresql':
|
||||
raise RuntimeError(f'{self.mode.value} persistence mode requires PostgreSQL')
|
||||
|
||||
if self.mode == PersistenceMode.CLOUD_RUNTIME:
|
||||
await self._validate_cloud_runtime()
|
||||
return
|
||||
|
||||
self._enable_sqlite_foreign_keys()
|
||||
await self._initialize_managed_schema()
|
||||
|
||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||
await self.write_space_model_providers()
|
||||
|
||||
async def _initialize_managed_schema(self) -> None:
|
||||
"""Create or migrate schema only in OSS and release processes."""
|
||||
from . import alembic_runner
|
||||
|
||||
engine = self.get_db_engine()
|
||||
release_bootstrap = self.mode == PersistenceMode.RELEASE_MIGRATION and await self._is_empty_schema()
|
||||
|
||||
await self.create_tables()
|
||||
|
||||
@@ -125,16 +159,36 @@ class PersistenceManager:
|
||||
|
||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||
|
||||
# Run Alembic migrations (new migration system)
|
||||
await self._run_alembic_migrations()
|
||||
if engine.dialect.name == 'postgresql':
|
||||
current_revision = await alembic_runner.get_alembic_current(engine)
|
||||
head_revision = alembic_runner.get_alembic_head()
|
||||
|
||||
# A legacy database may not contain tenant tables introduced by a
|
||||
# newer release. They were deliberately deferred before 0009 because
|
||||
# their Workspace/account FK targets did not exist yet; create them
|
||||
# now that the tenancy contract is in place.
|
||||
await self.create_tables()
|
||||
if release_bootstrap:
|
||||
# Base.metadata represents the complete 0010 schema. An empty
|
||||
# Cloud database has no legacy data to transform and must not
|
||||
# run 0009's OSS singleton Workspace bootstrap.
|
||||
if current_revision is not None:
|
||||
raise RuntimeError('Empty PostgreSQL release bootstrap unexpectedly has an Alembic revision')
|
||||
await alembic_runner.run_alembic_stamp(engine, _RESOURCE_SCOPE_ALEMBIC_REVISION)
|
||||
elif current_revision != head_revision:
|
||||
# A legacy database may not contain tenant tables introduced
|
||||
# by a newer release. Upgrade the account/resource contract,
|
||||
# then create deferred tables before the RLS migration runs.
|
||||
await self._run_alembic_migrations(_RESOURCE_SCOPE_ALEMBIC_REVISION)
|
||||
await self.create_tables()
|
||||
|
||||
await self.write_space_model_providers()
|
||||
await self._run_alembic_migrations()
|
||||
await self._validate_postgres_tenant_schema(validate_runtime_role=False)
|
||||
|
||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||
await self._install_oss_postgres_tenant_scope()
|
||||
else:
|
||||
await self._run_alembic_migrations()
|
||||
|
||||
# SQLite keeps the historical post-migration create_all pass. New
|
||||
# tenant tables are deferred until the Workspace/account schema is
|
||||
# compatible with their foreign keys.
|
||||
await self.create_tables()
|
||||
|
||||
async def create_tables(self):
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
@@ -173,6 +227,37 @@ class PersistenceManager:
|
||||
|
||||
await self._ensure_instance_uuid_metadata()
|
||||
|
||||
async def _is_empty_schema(self) -> bool:
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
table_names = await conn.run_sync(lambda sync_conn: set(sqlalchemy.inspect(sync_conn).get_table_names()))
|
||||
return not table_names
|
||||
|
||||
async def _install_oss_postgres_tenant_scope(self) -> None:
|
||||
"""Default every OSS PostgreSQL transaction to its singleton Workspace."""
|
||||
if getattr(self, '_oss_tenant_scope_listener_installed', False):
|
||||
return
|
||||
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
workspace_uuid = await conn.scalar(
|
||||
sqlalchemy.select(metadata.Metadata.value).where(
|
||||
metadata.Metadata.key == _OSS_WORKSPACE_METADATA_KEY,
|
||||
)
|
||||
)
|
||||
if not isinstance(workspace_uuid, str) or not workspace_uuid.strip():
|
||||
raise RuntimeError(
|
||||
'PostgreSQL OSS mode requires exactly one local Workspace recorded before tenant RLS is enabled'
|
||||
)
|
||||
workspace_uuid = workspace_uuid.strip()
|
||||
|
||||
def set_oss_tenant_scope(conn: sqlalchemy.Connection) -> None:
|
||||
conn.execute(
|
||||
sqlalchemy.text(f"SELECT set_config('{TENANT_SETTING}', :workspace_uuid, true)"),
|
||||
{'workspace_uuid': workspace_uuid},
|
||||
)
|
||||
|
||||
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
||||
self._oss_tenant_scope_listener_installed = True
|
||||
|
||||
def _enable_sqlite_foreign_keys(self) -> None:
|
||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||
engine = self.get_db_engine()
|
||||
@@ -215,6 +300,122 @@ class PersistenceManager:
|
||||
f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
|
||||
)
|
||||
|
||||
async def _validate_cloud_runtime(self) -> None:
|
||||
"""Validate a release-prepared schema without performing any DDL."""
|
||||
from . import alembic_runner
|
||||
|
||||
engine = self.get_db_engine()
|
||||
current_revision = await alembic_runner.get_alembic_current(engine)
|
||||
head_revision = alembic_runner.get_alembic_head()
|
||||
if current_revision != head_revision:
|
||||
raise RuntimeError(
|
||||
f'Cloud runtime database schema is not at the release head: {current_revision!r} != {head_revision!r}'
|
||||
)
|
||||
|
||||
runtime_instance_uuid = constants.instance_id.strip()
|
||||
if not runtime_instance_uuid:
|
||||
raise RuntimeError('LangBot instance UUID is empty before Cloud persistence validation')
|
||||
async with engine.connect() as conn:
|
||||
persisted_instance_uuid = await conn.scalar(
|
||||
sqlalchemy.select(metadata.Metadata.value).where(metadata.Metadata.key == 'instance_uuid')
|
||||
)
|
||||
if persisted_instance_uuid is None:
|
||||
raise RuntimeError("Cloud runtime database is missing metadata['instance_uuid']")
|
||||
if persisted_instance_uuid != runtime_instance_uuid:
|
||||
raise RuntimeError(
|
||||
'LangBot instance UUID does not match the value bound to this database: '
|
||||
f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
|
||||
)
|
||||
|
||||
await self._validate_postgres_tenant_schema(validate_runtime_role=True)
|
||||
|
||||
async def _validate_postgres_tenant_schema(self, *, validate_runtime_role: bool) -> None:
|
||||
"""Fail closed when PostgreSQL cannot enforce the tenant contract."""
|
||||
engine = self.get_db_engine()
|
||||
if engine.dialect.name != 'postgresql':
|
||||
raise RuntimeError('PostgreSQL tenant schema validation requires PostgreSQL')
|
||||
|
||||
table_query = sqlalchemy.text(
|
||||
"""
|
||||
SELECT
|
||||
c.relname AS table_name,
|
||||
c.relrowsecurity AS rls_enabled,
|
||||
c.relforcerowsecurity AS rls_forced,
|
||||
pg_get_userbyid(c.relowner) = current_user AS owned_by_runtime,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policy p
|
||||
WHERE p.polrelid = c.oid
|
||||
AND p.polname = :policy_name
|
||||
AND p.polcmd = '*'
|
||||
AND p.polpermissive
|
||||
AND p.polqual IS NOT NULL
|
||||
AND p.polwithcheck IS NOT NULL
|
||||
) AS policy_valid
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relkind IN ('r', 'p')
|
||||
AND c.relname IN :table_names
|
||||
"""
|
||||
).bindparams(sqlalchemy.bindparam('table_names', expanding=True))
|
||||
|
||||
async with engine.connect() as conn:
|
||||
if validate_runtime_role:
|
||||
role = (
|
||||
(
|
||||
await conn.execute(
|
||||
sqlalchemy.text(
|
||||
"""
|
||||
SELECT rolsuper, rolbypassrls
|
||||
FROM pg_roles
|
||||
WHERE rolname = current_user
|
||||
"""
|
||||
)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one_or_none()
|
||||
)
|
||||
if role is None:
|
||||
raise RuntimeError('Cloud runtime PostgreSQL role could not be inspected')
|
||||
if role['rolsuper']:
|
||||
raise RuntimeError('Cloud runtime PostgreSQL role must not be a superuser')
|
||||
if role['rolbypassrls']:
|
||||
raise RuntimeError('Cloud runtime PostgreSQL role must not have BYPASSRLS')
|
||||
|
||||
rows = (
|
||||
(
|
||||
await conn.execute(
|
||||
table_query,
|
||||
{
|
||||
'policy_name': TENANT_POLICY_NAME,
|
||||
'table_names': tuple(TENANT_TABLE_COLUMNS),
|
||||
},
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
by_table = {row['table_name']: row for row in rows}
|
||||
missing_tables = set(TENANT_TABLE_COLUMNS) - set(by_table)
|
||||
if missing_tables:
|
||||
raise RuntimeError(f'PostgreSQL tenant tables are missing: {sorted(missing_tables)!r}')
|
||||
|
||||
invalid_rls = sorted(
|
||||
table_name
|
||||
for table_name, row in by_table.items()
|
||||
if not row['rls_enabled'] or not row['rls_forced'] or not row['policy_valid']
|
||||
)
|
||||
if invalid_rls:
|
||||
raise RuntimeError(f'PostgreSQL tenant RLS contract is incomplete for tables: {invalid_rls!r}')
|
||||
|
||||
if validate_runtime_role:
|
||||
owned_tables = sorted(table_name for table_name, row in by_table.items() if row['owned_by_runtime'])
|
||||
if owned_tables:
|
||||
raise RuntimeError(f'Cloud runtime PostgreSQL role must not own tenant tables: {owned_tables!r}')
|
||||
|
||||
async def write_space_model_providers(self):
|
||||
if constants.edition != 'community':
|
||||
# SaaS Workspace/provider linkage is explicit control-plane state;
|
||||
@@ -277,7 +478,7 @@ class PersistenceManager:
|
||||
|
||||
# =================================
|
||||
|
||||
async def _run_alembic_migrations(self):
|
||||
async def _run_alembic_migrations(self, target_revision: str = 'head'):
|
||||
"""Run Alembic-based migrations after legacy migrations complete."""
|
||||
from . import alembic_runner
|
||||
|
||||
@@ -310,8 +511,8 @@ class PersistenceManager:
|
||||
# PostgreSQL has transactional DDL. SQLite has already crossed the
|
||||
# two destructive tenancy boundaries under verified backups; this
|
||||
# final call is a no-op today and applies future migrations.
|
||||
await alembic_runner.run_alembic_upgrade(engine, 'head')
|
||||
self.ap.logger.info('Alembic migrations completed.')
|
||||
await alembic_runner.run_alembic_upgrade(engine, target_revision)
|
||||
self.ap.logger.info(f'Alembic migrations completed at {target_revision}.')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
|
||||
raise
|
||||
@@ -359,6 +560,9 @@ class PersistenceManager:
|
||||
await conn.commit()
|
||||
return result
|
||||
|
||||
def tenant_uow(self, workspace_uuid: str) -> TenantUnitOfWork:
|
||||
return TenantUnitOfWork(self.get_db_engine(), workspace_uuid)
|
||||
|
||||
def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine:
|
||||
return self.db.get_engine()
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||
|
||||
|
||||
TENANT_SETTING = 'langbot.workspace_uuid'
|
||||
TENANT_POLICY_NAME = 'langbot_workspace_isolation'
|
||||
|
||||
# Keep this contract explicit. A new tenant-owned table must be added to both
|
||||
# this runtime list and the corresponding Alembic migration before release.
|
||||
TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'workspaces': 'uuid',
|
||||
'workspace_memberships': 'workspace_uuid',
|
||||
'workspace_invitations': 'workspace_uuid',
|
||||
'workspace_execution_states': 'workspace_uuid',
|
||||
'workspace_metadata': 'workspace_uuid',
|
||||
'api_keys': 'workspace_uuid',
|
||||
'bots': 'workspace_uuid',
|
||||
'bot_admins': 'workspace_uuid',
|
||||
'binary_storages': 'workspace_uuid',
|
||||
'mcp_servers': 'workspace_uuid',
|
||||
'model_providers': 'workspace_uuid',
|
||||
'llm_models': 'workspace_uuid',
|
||||
'embedding_models': 'workspace_uuid',
|
||||
'rerank_models': 'workspace_uuid',
|
||||
'legacy_pipelines': 'workspace_uuid',
|
||||
'pipeline_run_records': 'workspace_uuid',
|
||||
'plugin_settings': 'workspace_uuid',
|
||||
'knowledge_bases': 'workspace_uuid',
|
||||
'knowledge_base_files': 'workspace_uuid',
|
||||
'knowledge_base_chunks': 'workspace_uuid',
|
||||
'webhooks': 'workspace_uuid',
|
||||
'monitoring_messages': 'workspace_uuid',
|
||||
'monitoring_llm_calls': 'workspace_uuid',
|
||||
'monitoring_tool_calls': 'workspace_uuid',
|
||||
'monitoring_sessions': 'workspace_uuid',
|
||||
'monitoring_errors': 'workspace_uuid',
|
||||
'monitoring_embedding_calls': 'workspace_uuid',
|
||||
'monitoring_feedback': 'workspace_uuid',
|
||||
}
|
||||
|
||||
|
||||
class TenantUnitOfWork:
|
||||
"""Bind one Workspace scope to one database transaction.
|
||||
|
||||
PostgreSQL receives a transaction-local setting used by RLS policies.
|
||||
SQLite keeps the same transaction boundary without a database setting so
|
||||
OSS callers can adopt this API without changing database engines.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: sqlalchemy_asyncio.AsyncEngine, workspace_uuid: str) -> None:
|
||||
workspace_uuid = workspace_uuid.strip()
|
||||
if not workspace_uuid:
|
||||
raise ValueError('TenantUnitOfWork requires a non-empty workspace_uuid')
|
||||
|
||||
self._engine = engine
|
||||
self.workspace_uuid = workspace_uuid
|
||||
self._session: sqlalchemy_asyncio.AsyncSession | None = None
|
||||
self._used = False
|
||||
|
||||
@property
|
||||
def session(self) -> sqlalchemy_asyncio.AsyncSession:
|
||||
if self._session is None:
|
||||
raise RuntimeError('TenantUnitOfWork is not active')
|
||||
return self._session
|
||||
|
||||
async def __aenter__(self) -> TenantUnitOfWork:
|
||||
if self._used:
|
||||
raise RuntimeError('TenantUnitOfWork instances cannot be reused')
|
||||
self._used = True
|
||||
|
||||
session = sqlalchemy_asyncio.AsyncSession(self._engine, expire_on_commit=False)
|
||||
self._session = session
|
||||
try:
|
||||
await session.begin()
|
||||
if self._engine.dialect.name == 'postgresql':
|
||||
await session.execute(
|
||||
sqlalchemy.text(f"SELECT set_config('{TENANT_SETTING}', :workspace_uuid, true)"),
|
||||
{'workspace_uuid': self.workspace_uuid},
|
||||
)
|
||||
except BaseException:
|
||||
await session.rollback()
|
||||
await session.close()
|
||||
self._session = None
|
||||
raise
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: types.TracebackType | None,
|
||||
) -> bool:
|
||||
session = self.session
|
||||
try:
|
||||
if exc_type is None:
|
||||
await session.commit()
|
||||
else:
|
||||
await session.rollback()
|
||||
finally:
|
||||
await session.close()
|
||||
self._session = None
|
||||
return False
|
||||
|
||||
async def execute(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy.engine.Result[typing.Any]:
|
||||
return await self.session.execute(*args, **kwargs)
|
||||
|
||||
async def flush(self) -> None:
|
||||
await self.session.flush()
|
||||
@@ -30,6 +30,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
|
||||
from .mcp_policy import require_stdio_mcp_enabled, stdio_mcp_enabled
|
||||
|
||||
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
||||
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
||||
@@ -350,6 +351,11 @@ class RuntimeMCPSession:
|
||||
)
|
||||
|
||||
async def _init_stdio_python_server(self):
|
||||
# Final transport gate: this must run before both the Box branch and
|
||||
# the backwards-compatible host-stdio branch. Service/UI checks are
|
||||
# usability guards; this is the execution security boundary.
|
||||
require_stdio_mcp_enabled(self.ap, self.server_config)
|
||||
|
||||
if self._uses_box_stdio():
|
||||
await self._box_stdio_runtime.initialize()
|
||||
return
|
||||
@@ -1436,6 +1442,12 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
for server in servers:
|
||||
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
||||
if config.get('mode') == 'stdio' and not stdio_mcp_enabled(self.ap):
|
||||
self.ap.logger.info(
|
||||
f'Skipping disabled stdio MCP server {server.uuid}; '
|
||||
'the persisted configuration is retained but no process is launched'
|
||||
)
|
||||
continue
|
||||
try:
|
||||
binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
|
||||
execution_context = ExecutionContext(
|
||||
@@ -1511,6 +1523,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
"""
|
||||
execution_context = await self._assert_execution_active(context)
|
||||
server_config = dict(server_config)
|
||||
require_stdio_mcp_enabled(self.ap, server_config)
|
||||
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
|
||||
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
|
||||
raise ValueError('MCP server configuration belongs to another Workspace')
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
MCP_STDIO_DISABLED_CODE = 'mcp_stdio_disabled'
|
||||
MCP_STDIO_DISABLED_MESSAGE = 'Stdio MCP is disabled by instance policy'
|
||||
|
||||
|
||||
class MCPStdioDisabledError(RuntimeError):
|
||||
"""Raised when an instance-level policy refuses stdio MCP execution."""
|
||||
|
||||
code = MCP_STDIO_DISABLED_CODE
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(MCP_STDIO_DISABLED_MESSAGE)
|
||||
|
||||
|
||||
def stdio_mcp_enabled(ap: Any) -> bool:
|
||||
"""Return the independent instance-level stdio MCP feature gate.
|
||||
|
||||
The open-source default remains enabled for backwards compatibility. A
|
||||
deployment can disable it with ``mcp.stdio.enabled: false`` (or
|
||||
``MCP__STDIO__ENABLED=false``). Invalid values fail closed instead of
|
||||
accidentally enabling local process execution.
|
||||
"""
|
||||
|
||||
instance_config = getattr(ap, 'instance_config', None)
|
||||
config = getattr(instance_config, 'data', None)
|
||||
if not isinstance(config, dict):
|
||||
return False
|
||||
mcp_config = config.get('mcp', {})
|
||||
if not isinstance(mcp_config, dict):
|
||||
return False
|
||||
stdio_config = mcp_config.get('stdio', {})
|
||||
if not isinstance(stdio_config, dict):
|
||||
return False
|
||||
enabled = stdio_config.get('enabled', True)
|
||||
return enabled if isinstance(enabled, bool) else False
|
||||
|
||||
|
||||
def is_stdio_server(server_config: dict[str, Any] | None) -> bool:
|
||||
return isinstance(server_config, dict) and str(server_config.get('mode') or '').strip().lower() == 'stdio'
|
||||
|
||||
|
||||
def require_stdio_mcp_enabled(ap: Any, server_config: dict[str, Any] | None) -> None:
|
||||
"""Fail closed for a stdio server before choosing Box or host transport."""
|
||||
|
||||
if is_stdio_server(server_config) and not stdio_mcp_enabled(ap):
|
||||
raise MCPStdioDisabledError()
|
||||
@@ -125,9 +125,22 @@ plugin:
|
||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||
enable_marketplace: true
|
||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||
worker:
|
||||
# Instance-wide maximum for every plugin installation. Plugin
|
||||
# manifests cannot raise or override these limits.
|
||||
max_cpus: 1.0
|
||||
max_memory_mb: 512
|
||||
max_pids: 128
|
||||
max_open_files: 256
|
||||
max_file_size_mb: 512
|
||||
binary_storage:
|
||||
# Max bytes for a single plugin binary storage value
|
||||
max_value_bytes: 10485760
|
||||
mcp:
|
||||
stdio:
|
||||
# Independent gate for local stdio MCP transports. Cloud v2 sets
|
||||
# MCP__STDIO__ENABLED=false even when Box Runtime is available.
|
||||
enabled: true
|
||||
monitoring:
|
||||
auto_cleanup:
|
||||
# Enable automatic cleanup of expired monitoring records
|
||||
|
||||
Reference in New Issue
Block a user