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:
RockChinQ
2026-07-30 21:43:35 +08:00
committed by GitHub
parent 463b120923
commit e1ac5e0fc8
468 changed files with 78320 additions and 13137 deletions
+51
View File
@@ -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',
]
+408
View File
@@ -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
+311
View File
@@ -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
+251
View File
@@ -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),
}
+265
View File
@@ -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