mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-22 10:17:13 +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>
252 lines
10 KiB
Python
252 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from collections.abc import Callable
|
|
from typing import Protocol, runtime_checkable
|
|
|
|
import pydantic
|
|
|
|
|
|
class EntitlementUnavailableError(RuntimeError):
|
|
"""Raised when a trusted, currently-active entitlement is unavailable."""
|
|
|
|
def __init__(self, message: str, *, entitlement_revision: int | None = None) -> None:
|
|
super().__init__(message)
|
|
self.entitlement_revision = entitlement_revision
|
|
|
|
|
|
class EntitlementFeatureUnavailableError(EntitlementUnavailableError):
|
|
"""Raised only when an active entitlement does not grant one feature."""
|
|
|
|
def __init__(
|
|
self,
|
|
feature: str,
|
|
*,
|
|
entitlement_revision: int | None = None,
|
|
) -> None:
|
|
self.feature = feature
|
|
super().__init__(
|
|
f'Workspace entitlement does not grant {feature}',
|
|
entitlement_revision=entitlement_revision,
|
|
)
|
|
|
|
|
|
class EntitlementSnapshot(pydantic.BaseModel):
|
|
"""Capability projection consumed by open-source Core.
|
|
|
|
Admission and quota decisions use normalized features/limits rather than
|
|
product plan names. ``plan_name`` is signed display metadata for Cloud UI
|
|
only and must never drive authorization or quota enforcement.
|
|
"""
|
|
|
|
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)
|
|
# Signed display metadata for Cloud UI only. Admission and quota decisions
|
|
# must continue to use generic ``features`` and ``limits`` exclusively.
|
|
plan_name: str | None = pydantic.Field(default=None, min_length=1, max_length=128)
|
|
|
|
@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',
|
|
entitlement_revision=self.entitlement_revision,
|
|
)
|
|
if current_time < self.not_before or current_time >= self.expires_at:
|
|
raise EntitlementUnavailableError(
|
|
'Workspace entitlement is not currently valid',
|
|
entitlement_revision=self.entitlement_revision,
|
|
)
|
|
return self
|
|
|
|
def require_feature(self, feature: str) -> None:
|
|
if self.features.get(feature) is not True:
|
|
raise EntitlementFeatureUnavailableError(
|
|
feature,
|
|
entitlement_revision=self.entitlement_revision,
|
|
)
|
|
|
|
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,
|
|
*,
|
|
deployment_admission: Callable[[], object] | None = None,
|
|
) -> None:
|
|
self.instance_uuid = instance_uuid
|
|
self.provider = provider
|
|
self._deployment_admission = deployment_admission
|
|
self._lock = asyncio.Lock()
|
|
self._snapshots: dict[str, tuple[int, str, EntitlementSnapshot]] = {}
|
|
self._active_workspace_uuids: frozenset[str] | None = None
|
|
|
|
@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:
|
|
if self._deployment_admission is not None:
|
|
self._deployment_admission()
|
|
async with self._lock:
|
|
self._require_projected_workspace_locked(workspace_uuid)
|
|
candidate = await self.provider.get_workspace_entitlement(workspace_uuid)
|
|
if self._deployment_admission is not None:
|
|
# A provider call may cross the Manifest expiry boundary.
|
|
self._deployment_admission()
|
|
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:
|
|
# The directory may fence a Workspace while the provider call is
|
|
# in flight. Recheck before retaining or returning its snapshot.
|
|
self._require_projected_workspace_locked(workspace_uuid)
|
|
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)
|
|
|
|
def _require_projected_workspace_locked(self, workspace_uuid: str) -> None:
|
|
active_workspace_uuids = self._active_workspace_uuids
|
|
if active_workspace_uuids is not None and workspace_uuid not in active_workspace_uuids:
|
|
raise EntitlementUnavailableError('Workspace is not active in the Cloud directory projection')
|
|
|
|
async def reconcile_active_workspaces(
|
|
self,
|
|
workspace_uuids: set[str] | frozenset[str],
|
|
) -> None:
|
|
"""Drop entitlement history for Workspaces fenced by the directory."""
|
|
|
|
active = frozenset(workspace_uuids)
|
|
async with self._lock:
|
|
self._active_workspace_uuids = active
|
|
self._snapshots = {
|
|
workspace_uuid: cached for workspace_uuid, cached in self._snapshots.items() if workspace_uuid in active
|
|
}
|
|
|
|
async def set_workspace_active(
|
|
self,
|
|
workspace_uuid: str,
|
|
*,
|
|
active: bool,
|
|
) -> None:
|
|
"""Apply one incremental directory activity change."""
|
|
|
|
await self.update_workspace_activity(
|
|
active_workspace_uuids={workspace_uuid} if active else set(),
|
|
inactive_workspace_uuids=set() if active else {workspace_uuid},
|
|
)
|
|
|
|
async def update_workspace_activity(
|
|
self,
|
|
*,
|
|
active_workspace_uuids: set[str] | frozenset[str],
|
|
inactive_workspace_uuids: set[str] | frozenset[str],
|
|
) -> None:
|
|
"""Apply one directory delta without copying the active set per item."""
|
|
|
|
active_updates = set(active_workspace_uuids)
|
|
inactive_updates = set(inactive_workspace_uuids)
|
|
if active_updates & inactive_updates:
|
|
raise ValueError('Workspace activity update contains conflicting entries')
|
|
async with self._lock:
|
|
current = set(self._active_workspace_uuids or ())
|
|
current.update(active_updates)
|
|
current.difference_update(inactive_updates)
|
|
for workspace_uuid in inactive_updates:
|
|
self._snapshots.pop(workspace_uuid, None)
|
|
self._active_workspace_uuids = frozenset(current)
|
|
|
|
def snapshot_counts(self) -> dict[str, int]:
|
|
return {
|
|
'active_workspaces': len(self._active_workspace_uuids or ()),
|
|
'cached_snapshots': len(self._snapshots),
|
|
}
|