mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
e1ac5e0fc8
* 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>
169 lines
7.1 KiB
Python
169 lines
7.1 KiB
Python
"""ASGI integration: serve the LangBot MCP server alongside the Quart HTTP app.
|
|
|
|
The Quart app and the MCP server are both ASGI apps. We front them with a small
|
|
dispatcher ASGI callable:
|
|
|
|
- Requests whose path is (or is under) ``/mcp`` are authenticated with a
|
|
LangBot API key (reusing ``apikey_service.verify_api_key``, which also
|
|
accepts the global API key from ``config.yaml``) and then handed to the
|
|
FastMCP Starlette app.
|
|
- Every other request goes to the Quart app unchanged.
|
|
|
|
The FastMCP streamable-HTTP transport requires its session manager's lifespan
|
|
to be running. Rather than rely on the dispatcher receiving ASGI lifespan
|
|
events (Quart owns those), we explicitly run the session manager in a background
|
|
task managed by LangBot's task manager.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import typing
|
|
import uuid
|
|
|
|
from ..http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
|
from .context import bind_request_context, reset_request_context
|
|
from .server import LangBotMCPServer
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from ...core import app as app_module
|
|
|
|
|
|
# JSON-RPC-ish 401 body returned before the MCP app is reached.
|
|
_UNAUTHORIZED_BODY = b'{"error":"unauthorized","message":"A valid LangBot API key is required for MCP access."}'
|
|
_ENTITLEMENT_UNAVAILABLE_BODY = (
|
|
b'{"error":"entitlement_unavailable","message":"Workspace entitlement is unavailable for MCP access."}'
|
|
)
|
|
|
|
|
|
def _extract_api_key(headers: list[tuple[bytes, bytes]]) -> str:
|
|
"""Pull an API key from ASGI headers (X-API-Key or Authorization: Bearer)."""
|
|
header_map = {k.lower(): v for k, v in headers}
|
|
api_key = header_map.get(b'x-api-key', b'').decode('latin-1').strip()
|
|
if api_key:
|
|
return api_key
|
|
auth = header_map.get(b'authorization', b'').decode('latin-1').strip()
|
|
if auth.lower().startswith('bearer '):
|
|
return auth[7:].strip()
|
|
return ''
|
|
|
|
|
|
class MCPMount:
|
|
"""Owns the MCP server and produces the dispatcher ASGI app."""
|
|
|
|
MCP_PATH_PREFIX = '/mcp'
|
|
|
|
def __init__(self, ap: app_module.Application) -> None:
|
|
self.ap = ap
|
|
self.server = LangBotMCPServer(ap)
|
|
self._mcp_asgi = self.server.streamable_http_app()
|
|
self._lifespan_cm: typing.Any = None
|
|
|
|
async def start_session_manager(self) -> None:
|
|
"""Run the MCP session manager lifespan in the background.
|
|
|
|
StreamableHTTPSessionManager.run() is a one-shot async context manager
|
|
(it may only be entered once). We keep it open for the process lifetime;
|
|
it is torn down when the event loop stops.
|
|
"""
|
|
cm = self.server.session_manager.run()
|
|
self._lifespan_cm = cm
|
|
await cm.__aenter__()
|
|
|
|
async def stop_session_manager(self) -> None:
|
|
if self._lifespan_cm is not None:
|
|
with contextlib.suppress(Exception):
|
|
await self._lifespan_cm.__aexit__(None, None, None)
|
|
self._lifespan_cm = None
|
|
|
|
def _is_mcp_path(self, path: str) -> bool:
|
|
return path == self.MCP_PATH_PREFIX or path.startswith(self.MCP_PATH_PREFIX + '/')
|
|
|
|
def wrap(self, quart_asgi: typing.Callable) -> typing.Callable:
|
|
"""Return a dispatcher ASGI app fronting ``quart_asgi``."""
|
|
mcp_asgi = self._mcp_asgi
|
|
authenticate_api_key = self.ap.apikey_service.authenticate_api_key
|
|
is_mcp_path = self._is_mcp_path
|
|
|
|
async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def]
|
|
# Pass through non-HTTP scopes (lifespan, websocket) to Quart so its
|
|
# own startup/shutdown and websocket routes keep working.
|
|
if scope['type'] != 'http' or not is_mcp_path(scope.get('path', '')):
|
|
await quart_asgi(scope, receive, send)
|
|
return
|
|
|
|
# Authenticate MCP HTTP requests with a LangBot API key.
|
|
api_key = _extract_api_key(scope.get('headers', []))
|
|
identity = None
|
|
if api_key:
|
|
with contextlib.suppress(Exception):
|
|
identity = await authenticate_api_key(api_key)
|
|
|
|
if identity is None:
|
|
await send(
|
|
{
|
|
'type': 'http.response.start',
|
|
'status': 401,
|
|
'headers': [
|
|
(b'content-type', b'application/json'),
|
|
(b'www-authenticate', b'Bearer'),
|
|
],
|
|
}
|
|
)
|
|
await send({'type': 'http.response.body', 'body': _UNAUTHORIZED_BODY})
|
|
return
|
|
|
|
deployment_admission = getattr(self.ap, 'deployment_admission', None)
|
|
try:
|
|
if deployment_admission is not None:
|
|
deployment_admission.require_active()
|
|
entitlement_revision = 0
|
|
deployment = getattr(self.ap, 'deployment', None)
|
|
if deployment is not None and getattr(deployment, 'multi_workspace_enabled', False):
|
|
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
|
if resolver is None or identity.instance_uuid != resolver.instance_uuid:
|
|
raise RuntimeError('Workspace entitlement resolver is unavailable')
|
|
entitlement = await resolver.resolve(identity.workspace_uuid)
|
|
entitlement_revision = entitlement.entitlement_revision
|
|
except Exception:
|
|
await send(
|
|
{
|
|
'type': 'http.response.start',
|
|
'status': 403,
|
|
'headers': [(b'content-type', b'application/json')],
|
|
}
|
|
)
|
|
await send({'type': 'http.response.body', 'body': _ENTITLEMENT_UNAVAILABLE_BODY})
|
|
return
|
|
|
|
request_context = RequestContext(
|
|
instance_uuid=identity.instance_uuid,
|
|
placement_generation=identity.placement_generation,
|
|
request_id=str(uuid.uuid4()),
|
|
auth_type='api-key',
|
|
principal=PrincipalContext(
|
|
principal_type=PrincipalType.API_KEY,
|
|
api_key_uuid=identity.api_key_uuid,
|
|
),
|
|
workspace=WorkspaceContext(
|
|
workspace_uuid=identity.workspace_uuid,
|
|
membership_uuid=None,
|
|
role=None,
|
|
permissions=identity.permissions,
|
|
),
|
|
entitlement_revision=entitlement_revision,
|
|
)
|
|
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
|
if not callable(tenant_scope):
|
|
raise RuntimeError('MCP request persistence scope is unavailable')
|
|
async with tenant_scope(identity.workspace_uuid):
|
|
token = bind_request_context(request_context)
|
|
try:
|
|
await mcp_asgi(scope, receive, send)
|
|
if deployment_admission is not None:
|
|
deployment_admission.require_active()
|
|
finally:
|
|
reset_request_context(token)
|
|
|
|
return dispatcher
|