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,51 @@
|
||||
"""Contracts used by the optional closed Cloud control-plane bootstrap."""
|
||||
|
||||
from .bootstrap import (
|
||||
CloudBootstrapError,
|
||||
CloudManifestProvider,
|
||||
CloudManifestRefreshService,
|
||||
OpenSourceDeployment,
|
||||
VerifiedCloudDeployment,
|
||||
resolve_deployment,
|
||||
)
|
||||
from .directory import (
|
||||
DirectoryDelta,
|
||||
DirectoryEvent,
|
||||
DirectoryEventBatch,
|
||||
DirectoryMember,
|
||||
DirectoryProjectionProvider,
|
||||
DirectoryProjectionUnavailableError,
|
||||
DirectorySnapshot,
|
||||
DirectoryWorkspace,
|
||||
)
|
||||
from .directory_projection import DirectoryProjectionService
|
||||
from .entitlements import (
|
||||
EntitlementProvider,
|
||||
EntitlementResolver,
|
||||
EntitlementSnapshot,
|
||||
EntitlementUnavailableError,
|
||||
OpenSourceEntitlementProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'CloudBootstrapError',
|
||||
'CloudManifestProvider',
|
||||
'CloudManifestRefreshService',
|
||||
'DirectoryDelta',
|
||||
'DirectoryEvent',
|
||||
'DirectoryEventBatch',
|
||||
'DirectoryMember',
|
||||
'DirectoryProjectionProvider',
|
||||
'DirectoryProjectionService',
|
||||
'DirectoryProjectionUnavailableError',
|
||||
'DirectorySnapshot',
|
||||
'DirectoryWorkspace',
|
||||
'EntitlementProvider',
|
||||
'EntitlementResolver',
|
||||
'EntitlementSnapshot',
|
||||
'EntitlementUnavailableError',
|
||||
'OpenSourceDeployment',
|
||||
'OpenSourceEntitlementProvider',
|
||||
'VerifiedCloudDeployment',
|
||||
'resolve_deployment',
|
||||
]
|
||||
@@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
|
||||
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
||||
|
||||
|
||||
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
||||
REQUIRED_TENANT_ISOLATION_VERSION = 2
|
||||
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536})
|
||||
|
||||
|
||||
class CloudBootstrapError(RuntimeError):
|
||||
"""Fail-closed Cloud bootstrap validation error."""
|
||||
|
||||
|
||||
class CloudRuntimeUnavailableError(CloudBootstrapError):
|
||||
"""The verified Cloud receipt no longer admits runtime work."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CloudManifestProvider(Protocol):
|
||||
"""Closed adapter responsible for renewing the signed deployment receipt."""
|
||||
|
||||
async def refresh_manifest(self) -> VerifiedCloudDeployment:
|
||||
"""Fetch, verify, and return the newest deployment receipt."""
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Release control-plane transport resources."""
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
directory_provider: None = None
|
||||
manifest_provider: None = None
|
||||
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
|
||||
directory_provider: DirectoryProjectionProvider
|
||||
manifest_provider: CloudManifestProvider
|
||||
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')
|
||||
if not isinstance(self.directory_provider, DirectoryProjectionProvider):
|
||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
|
||||
if not isinstance(self.manifest_provider, CloudManifestProvider):
|
||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
|
||||
|
||||
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
||||
try:
|
||||
directory_projection_limits_from_config(config)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CloudBootstrapError(f'Cloud directory limits are invalid: {exc}') from exc
|
||||
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')
|
||||
pgvector_config = config.get('vdb', {}).get('pgvector', {})
|
||||
if pgvector_config.get('use_business_database') is not True:
|
||||
raise CloudBootstrapError('Cloud runtime requires vdb.pgvector.use_business_database=true')
|
||||
dimensions = pgvector_config.get('allowed_dimensions')
|
||||
if (
|
||||
not isinstance(dimensions, list)
|
||||
or not dimensions
|
||||
or any(isinstance(item, bool) or not isinstance(item, int) for item in dimensions)
|
||||
or not set(dimensions).issubset(SUPPORTED_PGVECTOR_DIMENSIONS)
|
||||
):
|
||||
supported = ', '.join(str(item) for item in sorted(SUPPORTED_PGVECTOR_DIMENSIONS))
|
||||
raise CloudBootstrapError(f'Cloud pgvector allowed_dimensions must be a non-empty subset of: {supported}')
|
||||
if config.get('mcp', {}).get('stdio', {}).get('enabled', True) is not False:
|
||||
raise CloudBootstrapError('Cloud runtime requires mcp.stdio.enabled=false')
|
||||
plugin_worker = config.get('plugin', {}).get('worker', {})
|
||||
if plugin_worker.get('require_hard_limits') is not True:
|
||||
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
|
||||
box_config = config.get('box', {})
|
||||
if box_config.get('enabled') is not True:
|
||||
raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
|
||||
if box_config.get('backend') != 'nsjail':
|
||||
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
|
||||
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
|
||||
if not runtime_endpoint:
|
||||
raise CloudBootstrapError('Cloud runtime requires a shared external box.runtime.endpoint')
|
||||
admission = box_config.get('admission', {})
|
||||
required_admission = {
|
||||
'required': True,
|
||||
'logical_session_id': 'global',
|
||||
'required_backend': 'nsjail',
|
||||
'max_sessions': 1,
|
||||
'max_managed_processes': 0,
|
||||
}
|
||||
if any(admission.get(name) != value for name, value in required_admission.items()):
|
||||
raise CloudBootstrapError(
|
||||
'Cloud runtime requires grant-enforced Box admission with one global session and zero managed processes'
|
||||
)
|
||||
grant_ttl = admission.get('max_grant_ttl_sec')
|
||||
if isinstance(grant_ttl, bool) or not isinstance(grant_ttl, int) or not 1 <= grant_ttl <= 300:
|
||||
raise CloudBootstrapError('Cloud Box admission max_grant_ttl_sec must be between 1 and 300')
|
||||
workspace_quota_mb = admission.get('workspace_quota_mb')
|
||||
if isinstance(workspace_quota_mb, bool) or not isinstance(workspace_quota_mb, int) or workspace_quota_mb <= 0:
|
||||
raise CloudBootstrapError('Cloud Box admission workspace_quota_mb must be a positive integer')
|
||||
local_config = box_config.get('local', {})
|
||||
host_root = str(local_config.get('host_root', '') or '').strip()
|
||||
default_workspace = str(local_config.get('default_workspace', '') or '').strip()
|
||||
allowed_mount_roots = local_config.get('allowed_mount_roots')
|
||||
if not host_root or not os.path.isabs(host_root):
|
||||
raise CloudBootstrapError('Cloud Box local.host_root must be an absolute shared-volume path')
|
||||
if not default_workspace or not os.path.isabs(default_workspace):
|
||||
raise CloudBootstrapError('Cloud Box local.default_workspace must be an absolute shared-volume path')
|
||||
if (
|
||||
not isinstance(allowed_mount_roots, list)
|
||||
or not allowed_mount_roots
|
||||
or any(not isinstance(root, str) or not os.path.isabs(root) for root in allowed_mount_roots)
|
||||
):
|
||||
raise CloudBootstrapError('Cloud Box local.allowed_mount_roots must contain absolute shared-volume paths')
|
||||
resolved_workspace = os.path.realpath(default_workspace)
|
||||
if not any(
|
||||
resolved_workspace == os.path.realpath(root)
|
||||
or resolved_workspace.startswith(f'{os.path.realpath(root)}{os.sep}')
|
||||
for root in allowed_mount_roots
|
||||
):
|
||||
raise CloudBootstrapError('Cloud Box local.default_workspace must be under allowed_mount_roots')
|
||||
|
||||
|
||||
class CloudBootstrapProvider(Protocol):
|
||||
def bootstrap(
|
||||
self,
|
||||
*,
|
||||
instance_uuid: str,
|
||||
instance_config: dict[str, Any],
|
||||
) -> VerifiedCloudDeployment | Awaitable[VerifiedCloudDeployment]: ...
|
||||
|
||||
|
||||
class DeploymentAdmissionGuard:
|
||||
"""Continuously enforce one verified deployment receipt.
|
||||
|
||||
Startup verification alone is insufficient because a long-running process
|
||||
could otherwise keep serving after the signed Manifest expires. The guard
|
||||
tracks both wall-clock expiry and a monotonic deadline so moving the system
|
||||
clock backwards cannot extend an already admitted receipt.
|
||||
|
||||
A closed bootstrap may atomically replace the receipt with a strictly newer
|
||||
Manifest generation after performing its own signature verification. The
|
||||
logical instance and deployment mode cannot change during the process.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
instance_uuid: str,
|
||||
deployment: OpenSourceDeployment | VerifiedCloudDeployment,
|
||||
*,
|
||||
wall_time: Callable[[], float] = time.time,
|
||||
monotonic_time: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.instance_uuid = instance_uuid
|
||||
self._wall_time = wall_time
|
||||
self._monotonic_time = monotonic_time
|
||||
self._lock = threading.Lock()
|
||||
self._deployment = deployment
|
||||
self._deadline: float | None = None
|
||||
self._install_initial(deployment)
|
||||
|
||||
@property
|
||||
def deployment(self) -> OpenSourceDeployment | VerifiedCloudDeployment:
|
||||
with self._lock:
|
||||
return self._deployment
|
||||
|
||||
def _install_initial(self, deployment: OpenSourceDeployment | VerifiedCloudDeployment) -> None:
|
||||
now = int(self._wall_time())
|
||||
if isinstance(deployment, VerifiedCloudDeployment):
|
||||
deployment.validate(self.instance_uuid, now=now)
|
||||
self._deadline = self._monotonic_time() + (deployment.expires_at - now)
|
||||
elif not isinstance(deployment, OpenSourceDeployment):
|
||||
raise TypeError('Deployment admission requires a verified deployment object')
|
||||
|
||||
@staticmethod
|
||||
def _receipt_identity(deployment: VerifiedCloudDeployment) -> tuple[Any, ...]:
|
||||
return (
|
||||
deployment.instance_uuid,
|
||||
deployment.manifest_jti,
|
||||
deployment.manifest_generation,
|
||||
deployment.expires_at,
|
||||
deployment.release,
|
||||
tuple(sorted(deployment.capabilities)),
|
||||
deployment.tenant_isolation_version,
|
||||
deployment.verification_key_id,
|
||||
)
|
||||
|
||||
def replace(self, deployment: VerifiedCloudDeployment) -> None:
|
||||
"""Atomically install a verified, non-rollback Cloud receipt."""
|
||||
|
||||
now = int(self._wall_time())
|
||||
deployment.validate(self.instance_uuid, now=now)
|
||||
with self._lock:
|
||||
current = self._deployment
|
||||
if not isinstance(current, VerifiedCloudDeployment):
|
||||
raise CloudRuntimeUnavailableError('Deployment mode cannot change while LangBot is running')
|
||||
if deployment.manifest_generation < current.manifest_generation:
|
||||
raise CloudRuntimeUnavailableError('Cloud Manifest generation rolled back')
|
||||
if deployment.manifest_generation == current.manifest_generation and self._receipt_identity(
|
||||
deployment
|
||||
) != self._receipt_identity(current):
|
||||
raise CloudRuntimeUnavailableError('Cloud Manifest generation has conflicting contents')
|
||||
self._deployment = deployment
|
||||
self._deadline = self._monotonic_time() + (deployment.expires_at - now)
|
||||
|
||||
def require_active(self) -> OpenSourceDeployment | VerifiedCloudDeployment:
|
||||
"""Return the active deployment or fail closed after Manifest expiry."""
|
||||
|
||||
now = int(self._wall_time())
|
||||
monotonic_now = self._monotonic_time()
|
||||
with self._lock:
|
||||
deployment = self._deployment
|
||||
deadline = self._deadline
|
||||
if isinstance(deployment, OpenSourceDeployment):
|
||||
return deployment
|
||||
try:
|
||||
deployment.validate(self.instance_uuid, now=now)
|
||||
except CloudBootstrapError as exc:
|
||||
raise CloudRuntimeUnavailableError(str(exc)) from exc
|
||||
if deadline is None or monotonic_now >= deadline:
|
||||
raise CloudRuntimeUnavailableError('Verified Cloud Manifest is expired')
|
||||
return deployment
|
||||
|
||||
|
||||
class CloudManifestRefreshService:
|
||||
"""Renew a short-lived verified Manifest before runtime admission expires."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
admission: DeploymentAdmissionGuard,
|
||||
provider: CloudManifestProvider,
|
||||
logger: Any,
|
||||
*,
|
||||
wall_time: Callable[[], float] = time.time,
|
||||
refresh_margin_seconds: int = 180,
|
||||
maximum_sleep_seconds: int = 300,
|
||||
) -> None:
|
||||
if not isinstance(provider, CloudManifestProvider):
|
||||
raise TypeError('Cloud Manifest refresh requires a CloudManifestProvider')
|
||||
if refresh_margin_seconds < 120:
|
||||
raise ValueError('Cloud Manifest refresh margin must be at least 120 seconds')
|
||||
if maximum_sleep_seconds <= 0:
|
||||
raise ValueError('Cloud Manifest refresh maximum sleep must be positive')
|
||||
self.admission = admission
|
||||
self.provider = provider
|
||||
self.logger = logger
|
||||
self._wall_time = wall_time
|
||||
self.refresh_margin_seconds = refresh_margin_seconds
|
||||
self.maximum_sleep_seconds = maximum_sleep_seconds
|
||||
|
||||
def next_refresh_delay(self) -> float:
|
||||
deployment = self.admission.deployment
|
||||
if not isinstance(deployment, VerifiedCloudDeployment):
|
||||
return float(self.maximum_sleep_seconds)
|
||||
remaining = deployment.expires_at - self._wall_time()
|
||||
return max(
|
||||
5.0,
|
||||
min(
|
||||
float(self.maximum_sleep_seconds),
|
||||
remaining - self.refresh_margin_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
async def refresh_once(self) -> VerifiedCloudDeployment:
|
||||
candidate = await self.provider.refresh_manifest()
|
||||
if not isinstance(candidate, VerifiedCloudDeployment):
|
||||
raise CloudBootstrapError('Cloud Manifest provider returned an invalid deployment receipt')
|
||||
self.admission.replace(candidate)
|
||||
return candidate
|
||||
|
||||
async def run(self) -> None:
|
||||
retry_delay = 5.0
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.next_refresh_delay())
|
||||
await self.refresh_once()
|
||||
retry_delay = 5.0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
self.logger.exception('Cloud Manifest refresh failed')
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 30.0)
|
||||
|
||||
|
||||
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,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import pydantic
|
||||
|
||||
|
||||
DEFAULT_MAX_ACTIVE_WORKSPACES = 1_000
|
||||
HARD_MAX_ACTIVE_WORKSPACES = 5_000
|
||||
DEFAULT_MAX_SNAPSHOT_WORKSPACES = 1_000
|
||||
HARD_MAX_SNAPSHOT_WORKSPACES = 5_000
|
||||
DEFAULT_MAX_SNAPSHOT_MEMBERSHIPS = 20_000
|
||||
HARD_MAX_SNAPSHOT_MEMBERSHIPS = 100_000
|
||||
DEFAULT_MAX_CONTROL_PLANE_RESPONSE_BYTES = 32 * 1024 * 1024
|
||||
HARD_MAX_CONTROL_PLANE_RESPONSE_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
class DirectoryProjectionUnavailableError(RuntimeError):
|
||||
"""Raised when the verified Cloud directory cannot safely admit work."""
|
||||
|
||||
|
||||
class DirectoryProjectionLimits(pydantic.BaseModel):
|
||||
"""Instance-owned cardinality limits for verified Cloud directory data.
|
||||
|
||||
These are operational safety limits, not subscription entitlements. Core
|
||||
fails the complete projection transaction when a limit is exceeded instead
|
||||
of truncating an authoritative directory and accidentally hiding tenants.
|
||||
The closed adapter consumes the same limits before priming entitlement
|
||||
caches, and additionally bounds the HTTP response buffered for signature
|
||||
verification.
|
||||
"""
|
||||
|
||||
model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
|
||||
|
||||
max_active_workspaces: int = pydantic.Field(
|
||||
default=DEFAULT_MAX_ACTIVE_WORKSPACES,
|
||||
ge=1,
|
||||
le=HARD_MAX_ACTIVE_WORKSPACES,
|
||||
)
|
||||
max_snapshot_workspaces: int = pydantic.Field(
|
||||
default=DEFAULT_MAX_SNAPSHOT_WORKSPACES,
|
||||
ge=1,
|
||||
le=HARD_MAX_SNAPSHOT_WORKSPACES,
|
||||
)
|
||||
max_snapshot_memberships: int = pydantic.Field(
|
||||
default=DEFAULT_MAX_SNAPSHOT_MEMBERSHIPS,
|
||||
ge=1,
|
||||
le=HARD_MAX_SNAPSHOT_MEMBERSHIPS,
|
||||
)
|
||||
max_response_bytes: int = pydantic.Field(
|
||||
default=DEFAULT_MAX_CONTROL_PLANE_RESPONSE_BYTES,
|
||||
ge=1024 * 1024,
|
||||
le=HARD_MAX_CONTROL_PLANE_RESPONSE_BYTES,
|
||||
)
|
||||
|
||||
@pydantic.field_validator(
|
||||
'max_active_workspaces',
|
||||
'max_snapshot_workspaces',
|
||||
'max_snapshot_memberships',
|
||||
'max_response_bytes',
|
||||
mode='before',
|
||||
)
|
||||
@classmethod
|
||||
def _reject_boolean_limits(cls, value: object) -> object:
|
||||
if isinstance(value, bool):
|
||||
raise ValueError('must be an integer')
|
||||
return value
|
||||
|
||||
@pydantic.model_validator(mode='after')
|
||||
def _validate_workspace_limits(self) -> DirectoryProjectionLimits:
|
||||
if self.max_snapshot_workspaces < self.max_active_workspaces:
|
||||
raise ValueError('max_snapshot_workspaces must be greater than or equal to max_active_workspaces')
|
||||
return self
|
||||
|
||||
|
||||
def directory_projection_limits_from_config(config: dict[str, Any]) -> DirectoryProjectionLimits:
|
||||
"""Parse typed Cloud directory limits from the instance configuration."""
|
||||
|
||||
cloud_config = config.get('cloud', {})
|
||||
if not isinstance(cloud_config, dict):
|
||||
raise ValueError('cloud must be a mapping')
|
||||
directory_config = cloud_config.get('directory', {})
|
||||
if not isinstance(directory_config, dict):
|
||||
raise ValueError('cloud.directory must be a mapping')
|
||||
return DirectoryProjectionLimits.model_validate(directory_config)
|
||||
|
||||
|
||||
class DirectoryMember(pydantic.BaseModel):
|
||||
"""One account membership published by the SaaS control plane."""
|
||||
|
||||
model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
|
||||
|
||||
membership_uuid: str = pydantic.Field(min_length=1, max_length=36)
|
||||
account_uuid: str = pydantic.Field(min_length=1, max_length=36)
|
||||
normalized_email: str = pydantic.Field(min_length=1, max_length=320)
|
||||
display_name: str = pydantic.Field(min_length=1, max_length=255)
|
||||
account_status: str = pydantic.Field(pattern=r'^(active|blocked|disabled|deleted)$')
|
||||
role: str = pydantic.Field(pattern=r'^(owner|admin|member|developer|operator|viewer)$')
|
||||
membership_status: str = pydantic.Field(pattern=r'^(active|invited|disabled|removed)$')
|
||||
projection_revision: int = pydantic.Field(ge=1)
|
||||
joined_at: datetime.datetime | None = None
|
||||
|
||||
@pydantic.field_validator('normalized_email')
|
||||
@classmethod
|
||||
def _normalize_email(cls, value: str) -> str:
|
||||
normalized = value.strip().casefold()
|
||||
if normalized != value:
|
||||
raise ValueError('Directory email must already be normalized')
|
||||
return normalized
|
||||
|
||||
|
||||
class DirectoryWorkspace(pydantic.BaseModel):
|
||||
"""One Workspace and its authoritative membership projection."""
|
||||
|
||||
model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
|
||||
|
||||
uuid: str = pydantic.Field(min_length=1, max_length=36)
|
||||
name: str = pydantic.Field(min_length=1, max_length=255)
|
||||
slug: str = pydantic.Field(min_length=1, max_length=255)
|
||||
type: str = pydantic.Field(pattern=r'^(personal|team)$')
|
||||
status: str = pydantic.Field(pattern=r'^(provisioning|active|suspended|archived|deleted)$')
|
||||
created_by_account_uuid: str = pydantic.Field(min_length=1, max_length=36)
|
||||
projection_revision: int = pydantic.Field(ge=1)
|
||||
execution_generation: int = pydantic.Field(ge=1)
|
||||
members: tuple[DirectoryMember, ...] = pydantic.Field(
|
||||
default=(),
|
||||
max_length=HARD_MAX_SNAPSHOT_MEMBERSHIPS,
|
||||
)
|
||||
|
||||
@pydantic.field_validator('members', mode='before')
|
||||
@classmethod
|
||||
def _copy_members(cls, value: Sequence[DirectoryMember] | None) -> tuple[DirectoryMember, ...]:
|
||||
return tuple(value or ())
|
||||
|
||||
@pydantic.model_validator(mode='after')
|
||||
def _validate_members(self) -> DirectoryWorkspace:
|
||||
membership_uuids: set[str] = set()
|
||||
account_uuids: set[str] = set()
|
||||
for member in self.members:
|
||||
if member.membership_uuid in membership_uuids:
|
||||
raise ValueError('Directory Workspace contains duplicate membership UUIDs')
|
||||
if member.account_uuid in account_uuids:
|
||||
raise ValueError('Directory Workspace contains duplicate account UUIDs')
|
||||
membership_uuids.add(member.membership_uuid)
|
||||
account_uuids.add(member.account_uuid)
|
||||
if self.created_by_account_uuid not in account_uuids:
|
||||
raise ValueError('Directory Workspace must include its creator')
|
||||
return self
|
||||
|
||||
|
||||
class DirectorySnapshot(pydantic.BaseModel):
|
||||
"""Full signed directory state at one monotonic outbox cursor."""
|
||||
|
||||
model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
|
||||
|
||||
instance_uuid: str = pydantic.Field(min_length=1, max_length=255)
|
||||
cursor: int = pydantic.Field(ge=0)
|
||||
generated_at: datetime.datetime
|
||||
workspaces: tuple[DirectoryWorkspace, ...] = pydantic.Field(
|
||||
default=(),
|
||||
max_length=HARD_MAX_SNAPSHOT_WORKSPACES,
|
||||
)
|
||||
|
||||
@pydantic.field_validator('workspaces', mode='before')
|
||||
@classmethod
|
||||
def _copy_workspaces(cls, value: Sequence[DirectoryWorkspace] | None) -> tuple[DirectoryWorkspace, ...]:
|
||||
return tuple(value or ())
|
||||
|
||||
@pydantic.model_validator(mode='after')
|
||||
def _validate_workspaces(self) -> DirectorySnapshot:
|
||||
workspace_uuids: set[str] = set()
|
||||
slugs: set[str] = set()
|
||||
membership_uuids: set[str] = set()
|
||||
for workspace in self.workspaces:
|
||||
if workspace.uuid in workspace_uuids:
|
||||
raise ValueError('Directory snapshot contains duplicate Workspace UUIDs')
|
||||
if workspace.slug in slugs:
|
||||
raise ValueError('Directory snapshot contains duplicate Workspace slugs')
|
||||
workspace_uuids.add(workspace.uuid)
|
||||
slugs.add(workspace.slug)
|
||||
for member in workspace.members:
|
||||
if member.membership_uuid in membership_uuids:
|
||||
raise ValueError('Directory snapshot contains duplicate membership UUIDs')
|
||||
membership_uuids.add(member.membership_uuid)
|
||||
return self
|
||||
|
||||
|
||||
class DirectoryDelta(pydantic.BaseModel):
|
||||
"""Signed authoritative state for an explicitly requested Workspace set."""
|
||||
|
||||
model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
|
||||
|
||||
instance_uuid: str = pydantic.Field(min_length=1, max_length=255)
|
||||
requested_workspace_uuids: tuple[str, ...]
|
||||
generated_at: datetime.datetime
|
||||
workspaces: tuple[DirectoryWorkspace, ...] = ()
|
||||
|
||||
@pydantic.field_validator('requested_workspace_uuids', mode='before')
|
||||
@classmethod
|
||||
def _copy_requested_workspace_uuids(cls, value: Sequence[str]) -> tuple[str, ...]:
|
||||
return tuple(value)
|
||||
|
||||
@pydantic.field_validator('workspaces', mode='before')
|
||||
@classmethod
|
||||
def _copy_workspaces(cls, value: Sequence[DirectoryWorkspace] | None) -> tuple[DirectoryWorkspace, ...]:
|
||||
return tuple(value or ())
|
||||
|
||||
@pydantic.model_validator(mode='after')
|
||||
def _validate_workspaces(self) -> DirectoryDelta:
|
||||
requested = self.requested_workspace_uuids
|
||||
if not requested or len(requested) > 100:
|
||||
raise ValueError('Directory delta must request between 1 and 100 Workspaces')
|
||||
if any(not workspace_uuid or len(workspace_uuid) > 36 for workspace_uuid in requested):
|
||||
raise ValueError('Directory delta contains an invalid requested Workspace UUID')
|
||||
if len(requested) != len(set(requested)):
|
||||
raise ValueError('Directory delta contains duplicate requested Workspace UUIDs')
|
||||
|
||||
requested_set = set(requested)
|
||||
workspace_uuids: set[str] = set()
|
||||
slugs: set[str] = set()
|
||||
membership_uuids: set[str] = set()
|
||||
for workspace in self.workspaces:
|
||||
if workspace.uuid in workspace_uuids:
|
||||
raise ValueError('Directory delta contains duplicate Workspace UUIDs')
|
||||
if workspace.uuid not in requested_set:
|
||||
raise ValueError('Directory delta returned an unrequested Workspace')
|
||||
if workspace.slug in slugs:
|
||||
raise ValueError('Directory delta contains duplicate Workspace slugs')
|
||||
workspace_uuids.add(workspace.uuid)
|
||||
slugs.add(workspace.slug)
|
||||
for member in workspace.members:
|
||||
if member.membership_uuid in membership_uuids:
|
||||
raise ValueError('Directory delta contains duplicate membership UUIDs')
|
||||
membership_uuids.add(member.membership_uuid)
|
||||
return self
|
||||
|
||||
|
||||
class DirectoryEvent(pydantic.BaseModel):
|
||||
"""One signed control-plane outbox notification."""
|
||||
|
||||
model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
|
||||
|
||||
cursor: int = pydantic.Field(ge=1)
|
||||
uuid: str = pydantic.Field(min_length=1, max_length=36)
|
||||
aggregate_uuid: str = pydantic.Field(min_length=1, max_length=36)
|
||||
event_type: str = pydantic.Field(min_length=1, max_length=128)
|
||||
revision: int = pydantic.Field(ge=1)
|
||||
payload: dict[str, Any] = pydantic.Field(default_factory=dict)
|
||||
created_at: datetime.datetime
|
||||
|
||||
|
||||
class DirectoryEventBatch(pydantic.BaseModel):
|
||||
"""Signed events returned after a caller-supplied directory cursor."""
|
||||
|
||||
model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
|
||||
|
||||
instance_uuid: str = pydantic.Field(min_length=1, max_length=255)
|
||||
after_cursor: int = pydantic.Field(ge=0)
|
||||
cursor: int = pydantic.Field(ge=0)
|
||||
high_water_cursor: int = pydantic.Field(ge=0)
|
||||
events: tuple[DirectoryEvent, ...] = ()
|
||||
|
||||
@pydantic.field_validator('events', mode='before')
|
||||
@classmethod
|
||||
def _copy_events(cls, value: Sequence[DirectoryEvent] | None) -> tuple[DirectoryEvent, ...]:
|
||||
return tuple(value or ())
|
||||
|
||||
@pydantic.model_validator(mode='after')
|
||||
def _validate_events(self) -> DirectoryEventBatch:
|
||||
if self.cursor < self.after_cursor:
|
||||
raise ValueError('Directory event cursor rolled back')
|
||||
if self.high_water_cursor < self.cursor:
|
||||
raise ValueError('Directory event high-water mark rolled back')
|
||||
event_cursors = [event.cursor for event in self.events]
|
||||
event_uuids = [event.uuid for event in self.events]
|
||||
if event_cursors != sorted(event_cursors) or len(event_cursors) != len(set(event_cursors)):
|
||||
raise ValueError('Directory events must have strictly increasing cursors')
|
||||
if len(event_uuids) != len(set(event_uuids)):
|
||||
raise ValueError('Directory event batch contains duplicate UUIDs')
|
||||
if any(cursor <= self.after_cursor or cursor > self.cursor for cursor in event_cursors):
|
||||
raise ValueError('Directory event falls outside the requested cursor window')
|
||||
if not self.events and (self.cursor != self.after_cursor or self.high_water_cursor != self.after_cursor):
|
||||
raise ValueError('Empty Directory event batch cannot advance or trail the high-water mark')
|
||||
if self.events and self.cursor != self.events[-1].cursor:
|
||||
raise ValueError('Directory event batch cursor must equal its final event cursor')
|
||||
return self
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DirectoryProjectionProvider(Protocol):
|
||||
"""Closed adapter that returns signature-verified control-plane data."""
|
||||
|
||||
async def fetch_snapshot(self, instance_uuid: str) -> DirectorySnapshot:
|
||||
"""Fetch and verify an authoritative full snapshot."""
|
||||
|
||||
async def fetch_events(
|
||||
self,
|
||||
instance_uuid: str,
|
||||
after_cursor: int,
|
||||
limit: int,
|
||||
) -> DirectoryEventBatch:
|
||||
"""Fetch and verify directory events after one process-local cursor."""
|
||||
|
||||
async def fetch_workspaces(
|
||||
self,
|
||||
instance_uuid: str,
|
||||
workspace_uuids: tuple[str, ...],
|
||||
) -> DirectoryDelta:
|
||||
"""Fetch and verify authoritative state for an explicit Workspace set."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
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),
|
||||
}
|
||||
@@ -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