mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
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>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import typing
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core.app import Application
|
||||
|
||||
|
||||
CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
|
||||
LAUNCH_KIND = 'workspace.launch'
|
||||
EXPECTED_ISSUER = 'langbot-space'
|
||||
EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
|
||||
_CONSUMED_JTI_MAX_ENTRIES = 4096
|
||||
_CONSUMED_JTI_HEAP_COMPACT_FLOOR = 64
|
||||
_CONSUMED_JTI_HEAP_MAX_MULTIPLIER = 4
|
||||
|
||||
|
||||
class SpaceLaunchError(ValueError):
|
||||
"""Raised when a Space-issued Cloud launch assertion is not admissible."""
|
||||
|
||||
|
||||
def _decode_base64url(value: str, *, label: str) -> bytes:
|
||||
if not value or any(character.isspace() for character in value):
|
||||
raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url')
|
||||
try:
|
||||
raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise SpaceLaunchError(f'Launch assertion {label} is not valid base64url') from exc
|
||||
if base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') != value:
|
||||
raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url')
|
||||
return raw
|
||||
|
||||
|
||||
def _strict_json_object(value: bytes, *, label: str) -> dict[str, typing.Any]:
|
||||
def reject_duplicate_keys(pairs: Iterable[tuple[str, typing.Any]]) -> dict[str, typing.Any]:
|
||||
result: dict[str, typing.Any] = {}
|
||||
for key, item in pairs:
|
||||
if key in result:
|
||||
raise SpaceLaunchError(f'Launch assertion {label} contains duplicate key {key!r}')
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
try:
|
||||
decoded = json.loads(value, object_pairs_hook=reject_duplicate_keys)
|
||||
except SpaceLaunchError:
|
||||
raise
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SpaceLaunchError(f'Launch assertion {label} is not valid JSON') from exc
|
||||
if not isinstance(decoded, dict):
|
||||
raise SpaceLaunchError(f'Launch assertion {label} must be a JSON object')
|
||||
return decoded
|
||||
|
||||
|
||||
def _required_string(claims: dict[str, typing.Any], name: str) -> str:
|
||||
value = claims.get(name)
|
||||
if not isinstance(value, str) or not value or value != value.strip():
|
||||
raise SpaceLaunchError(f'Launch assertion claim {name} must be a non-empty string')
|
||||
return value
|
||||
|
||||
|
||||
def _required_int(claims: dict[str, typing.Any], name: str, *, minimum: int = 0) -> int:
|
||||
value = claims.get(name)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||
raise SpaceLaunchError(f'Launch assertion claim {name} must be an integer >= {minimum}')
|
||||
return value
|
||||
|
||||
|
||||
def _load_ed25519_public_key(encoded: str) -> Ed25519PublicKey:
|
||||
value = encoded.strip()
|
||||
if value.startswith('-----BEGIN'):
|
||||
try:
|
||||
key = serialization.load_pem_public_key(value.encode('ascii'))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise SpaceLaunchError('Space launch public key is not valid PEM') from exc
|
||||
if not isinstance(key, Ed25519PublicKey):
|
||||
raise SpaceLaunchError('Space launch public key must be Ed25519')
|
||||
return key
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise SpaceLaunchError('Space launch public key must be base64 encoded') from exc
|
||||
if len(raw) != 32:
|
||||
raise SpaceLaunchError('Space launch Ed25519 public key must contain 32 bytes')
|
||||
return Ed25519PublicKey.from_public_bytes(raw)
|
||||
|
||||
|
||||
class SpaceLaunchService:
|
||||
"""Verify and single-use consume Space Cloud direct-launch assertions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: Application,
|
||||
*,
|
||||
wall_time: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.ap = ap
|
||||
self._wall_time = wall_time
|
||||
self._replay_lock = asyncio.Lock()
|
||||
self._consumed_jtis: dict[str, int] = {}
|
||||
self._consumed_jti_expiry_heap: list[tuple[int, str]] = []
|
||||
|
||||
async def consume_assertion(
|
||||
self,
|
||||
assertion: str,
|
||||
*,
|
||||
expected_workspace_uuid: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
claims = self._verify_assertion(assertion)
|
||||
payload = claims.get('payload')
|
||||
if not isinstance(payload, dict):
|
||||
raise SpaceLaunchError('Launch assertion payload must be a JSON object')
|
||||
account_uuid = _required_string(payload, 'account_uuid')
|
||||
workspace_uuid = _required_string(payload, 'workspace_uuid')
|
||||
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
|
||||
raise SpaceLaunchError('Launch assertion targets another Workspace')
|
||||
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
|
||||
return {
|
||||
'account_uuid': account_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
}
|
||||
|
||||
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
|
||||
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
||||
raise SpaceLaunchError('Space direct launch requires verified Cloud mode')
|
||||
public_key, key_id, clock_skew_seconds = self._trust_config()
|
||||
segments = token.split('.')
|
||||
if len(segments) != 3:
|
||||
raise SpaceLaunchError('Launch assertion must be a compact JWS')
|
||||
encoded_header, encoded_claims, encoded_signature = segments
|
||||
header = _strict_json_object(_decode_base64url(encoded_header, label='header'), label='header')
|
||||
if set(header) != {'alg', 'kid', 'typ'}:
|
||||
raise SpaceLaunchError('Launch assertion header contains unsupported fields')
|
||||
if header.get('alg') != 'EdDSA':
|
||||
raise SpaceLaunchError('Launch assertion algorithm must be EdDSA')
|
||||
if header.get('kid') != key_id:
|
||||
raise SpaceLaunchError('Launch assertion key ID does not match Cloud trust')
|
||||
if header.get('typ') != CONTROL_PLANE_TYP:
|
||||
raise SpaceLaunchError('Launch assertion type is not a control-plane payload')
|
||||
|
||||
signature = _decode_base64url(encoded_signature, label='signature')
|
||||
if len(signature) != 64:
|
||||
raise SpaceLaunchError('Launch assertion signature must contain 64 bytes')
|
||||
try:
|
||||
public_key.verify(signature, f'{encoded_header}.{encoded_claims}'.encode('ascii'))
|
||||
except InvalidSignature as exc:
|
||||
raise SpaceLaunchError('Launch assertion signature is invalid') from exc
|
||||
|
||||
claims = _strict_json_object(_decode_base64url(encoded_claims, label='claims'), label='claims')
|
||||
instance_uuid = self.ap.workspace_service.instance_uuid
|
||||
if _required_string(claims, 'iss') != EXPECTED_ISSUER:
|
||||
raise SpaceLaunchError('Launch assertion issuer is not LangBot Space')
|
||||
if _required_string(claims, 'aud') != EXPECTED_AUDIENCE:
|
||||
raise SpaceLaunchError('Launch assertion audience does not target Cloud runtime')
|
||||
if _required_string(claims, 'sub') != f'langbot-instance:{instance_uuid}':
|
||||
raise SpaceLaunchError('Launch assertion subject targets another instance')
|
||||
if _required_string(claims, 'instance_uuid') != instance_uuid:
|
||||
raise SpaceLaunchError('Launch assertion instance UUID does not match this Core')
|
||||
if _required_string(claims, 'kind') != LAUNCH_KIND:
|
||||
raise SpaceLaunchError('Launch assertion kind is not workspace.launch')
|
||||
|
||||
issued_at = _required_int(claims, 'iat')
|
||||
not_before = _required_int(claims, 'nbf')
|
||||
expires_at = _required_int(claims, 'exp', minimum=1)
|
||||
now = self._wall_time()
|
||||
if issued_at > now + clock_skew_seconds:
|
||||
raise SpaceLaunchError('Launch assertion was issued in the future')
|
||||
if not_before > now + clock_skew_seconds:
|
||||
raise SpaceLaunchError('Launch assertion is not active yet')
|
||||
if expires_at <= now - clock_skew_seconds:
|
||||
raise SpaceLaunchError('Launch assertion is expired')
|
||||
if expires_at <= max(issued_at, not_before):
|
||||
raise SpaceLaunchError('Launch assertion expiry must follow issue time')
|
||||
return claims
|
||||
|
||||
def _trust_config(self) -> tuple[Ed25519PublicKey, str, float]:
|
||||
data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
|
||||
space_config = data.get('space', {})
|
||||
launch_config = space_config.get('launch', {}) if isinstance(space_config, dict) else {}
|
||||
if not isinstance(launch_config, dict):
|
||||
launch_config = {}
|
||||
public_key_value = (
|
||||
os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY', '').strip()
|
||||
or str(launch_config.get('control_plane_public_key', '') or '').strip()
|
||||
)
|
||||
key_id = (
|
||||
os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_KEY_ID', '').strip()
|
||||
or str(launch_config.get('control_plane_key_id', '') or '').strip()
|
||||
or str(getattr(getattr(self.ap, 'deployment', None), 'verification_key_id', '') or '').strip()
|
||||
)
|
||||
if not public_key_value or not key_id:
|
||||
raise SpaceLaunchError('Space launch control-plane trust is not configured')
|
||||
clock_skew = self._bounded_float(
|
||||
os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_CLOCK_SKEW_SECONDS') or launch_config.get('clock_skew_seconds'),
|
||||
default=30.0,
|
||||
minimum=0.0,
|
||||
maximum=300.0,
|
||||
)
|
||||
return _load_ed25519_public_key(public_key_value), key_id, clock_skew
|
||||
|
||||
async def _consume_jti(self, jti: str, expires_at: int) -> None:
|
||||
digest = hashlib.sha256(jti.encode('utf-8')).hexdigest()
|
||||
now = int(self._wall_time())
|
||||
async with self._replay_lock:
|
||||
self._prune_consumed_jtis(now)
|
||||
if digest in self._consumed_jtis:
|
||||
raise SpaceLaunchError('Launch assertion has already been consumed')
|
||||
if len(self._consumed_jtis) >= _CONSUMED_JTI_MAX_ENTRIES:
|
||||
# Evicting a still-valid digest would make a signed launch
|
||||
# assertion replayable. Bound memory by failing closed instead.
|
||||
raise SpaceLaunchError('Launch assertion replay cache capacity reached')
|
||||
self._consumed_jtis[digest] = expires_at
|
||||
heapq.heappush(
|
||||
self._consumed_jti_expiry_heap,
|
||||
(expires_at, digest),
|
||||
)
|
||||
|
||||
def _prune_consumed_jtis(self, now: int) -> None:
|
||||
while self._consumed_jti_expiry_heap:
|
||||
expires_at, digest = self._consumed_jti_expiry_heap[0]
|
||||
current_expiry = self._consumed_jtis.get(digest)
|
||||
if current_expiry != expires_at:
|
||||
heapq.heappop(self._consumed_jti_expiry_heap)
|
||||
continue
|
||||
if expires_at > now:
|
||||
break
|
||||
heapq.heappop(self._consumed_jti_expiry_heap)
|
||||
self._consumed_jtis.pop(digest, None)
|
||||
|
||||
max_heap_entries = max(
|
||||
_CONSUMED_JTI_HEAP_COMPACT_FLOOR,
|
||||
len(self._consumed_jtis) * _CONSUMED_JTI_HEAP_MAX_MULTIPLIER,
|
||||
)
|
||||
if len(self._consumed_jti_expiry_heap) > max_heap_entries:
|
||||
self._consumed_jti_expiry_heap[:] = [(expiry, digest) for digest, expiry in self._consumed_jtis.items()]
|
||||
heapq.heapify(self._consumed_jti_expiry_heap)
|
||||
|
||||
@staticmethod
|
||||
def _bounded_float(
|
||||
value: typing.Any,
|
||||
*,
|
||||
default: float,
|
||||
minimum: float,
|
||||
maximum: float,
|
||||
) -> float:
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if not minimum <= result <= maximum:
|
||||
return default
|
||||
return result
|
||||
Reference in New Issue
Block a user