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
+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."""