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
+25
View File
@@ -0,0 +1,25 @@
from .errors import (
WorkspaceExecutionUnavailableError,
WorkspaceGenerationMismatchError,
WorkspaceInvariantError,
WorkspaceLimitExceededError,
WorkspaceNotFoundError,
WorkspaceOwnerAlreadyExistsError,
)
from .entities import WorkspaceExecutionBinding
from .policy import SingleWorkspacePolicy
from .repository import WorkspaceRepository
from .service import WorkspaceService
__all__ = [
'SingleWorkspacePolicy',
'WorkspaceExecutionBinding',
'WorkspaceExecutionUnavailableError',
'WorkspaceGenerationMismatchError',
'WorkspaceInvariantError',
'WorkspaceLimitExceededError',
'WorkspaceNotFoundError',
'WorkspaceOwnerAlreadyExistsError',
'WorkspaceRepository',
'WorkspaceService',
]
+820
View File
@@ -0,0 +1,820 @@
from __future__ import annotations
import dataclasses
import datetime
import hashlib
import secrets
import typing
import uuid
import asyncio
from contextlib import asynccontextmanager
from collections.abc import Awaitable, Callable
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ..entity.persistence.user import AccountStatus, User
from ..entity.persistence.workspace import (
InvitationStatus,
MembershipRole,
MembershipStatus,
Workspace,
WorkspaceInvitation,
WorkspaceMembership,
WorkspaceStatus,
)
from .entities import WorkspaceExecutionBinding
from .errors import WorkspaceExecutionUnavailableError, WorkspaceInvariantError, WorkspaceNotFoundError
from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
from .service import WorkspaceService
if typing.TYPE_CHECKING:
from ..core.app import Application
class WorkspaceCollaborationError(Exception):
"""Stable collaboration error surfaced by the Workspace API."""
code = 'workspace_collaboration_error'
class MembershipNotFoundError(WorkspaceCollaborationError):
code = 'membership_not_found'
class MembershipPermissionError(WorkspaceCollaborationError):
code = 'permission_denied'
class LastOwnerError(WorkspaceCollaborationError):
code = 'last_owner_required'
class InvitationError(WorkspaceCollaborationError):
code = 'invitation_invalid'
class InvitationExpiredError(InvitationError):
code = 'invitation_expired'
class InvitationRevokedError(InvitationError):
code = 'invitation_revoked'
class InvitationUsedError(InvitationError):
code = 'invitation_used'
class InvitationEmailMismatchError(InvitationError):
code = 'invitation_email_mismatch'
class InvitationRoleError(InvitationError):
code = 'invitation_role_invalid'
class AlreadyMemberError(InvitationError):
code = 'already_a_member'
@dataclasses.dataclass(frozen=True, slots=True)
class ResolvedWorkspaceAccess:
workspace: Workspace
membership: WorkspaceMembership
execution: WorkspaceExecutionBinding
@dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceMemberView:
membership: WorkspaceMembership
email: str
@dataclasses.dataclass(frozen=True, slots=True)
class CreatedInvitation:
invitation: WorkspaceInvitation
token: str
@dataclasses.dataclass(slots=True)
class _InvitationLockEntry:
lock: asyncio.Lock
users: int = 0
T = typing.TypeVar('T')
def normalize_email(email: str) -> str:
"""Return the canonical email identity used by invitations."""
normalized = email.strip().casefold()
if not normalized or '@' not in normalized:
raise ValueError('A valid email address is required')
if len(normalized) > 320:
raise ValueError('Email address exceeds the normalized identity limit')
return normalized
def hash_invitation_token(token: str) -> str:
"""Hash an invitation bearer secret for lookup and at-rest storage."""
return hashlib.sha256(token.encode('utf-8')).hexdigest()
class WorkspaceCollaborationService:
"""Membership and invitation operations for the local Workspace directory."""
def __init__(
self,
ap: Application,
workspace_service: WorkspaceService,
*,
policy: SingleWorkspacePolicy | CloudWorkspacePolicy | None = None,
) -> None:
self.ap = ap
self.workspace_service = workspace_service
self.policy = policy or workspace_service.policy
self._invitation_locks: dict[str, _InvitationLockEntry] = {}
self._invitation_locks_guard = asyncio.Lock()
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(
self.ap.persistence_mgr.get_db_engine(),
expire_on_commit=False,
)
async def resolve_account_workspace(
self,
account_uuid: str,
requested_workspace_uuid: str | None,
*,
session: AsyncSession | None = None,
) -> ResolvedWorkspaceAccess:
"""Resolve a selector against an active Account membership."""
normalized_workspace_uuid = requested_workspace_uuid.strip() if requested_workspace_uuid else None
if normalized_workspace_uuid is None and self.policy.multi_workspace_enabled:
raise WorkspaceNotFoundError('A Workspace selector is required')
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if session is None and normalized_workspace_uuid is not None and callable(tenant_uow):
async with tenant_uow(normalized_workspace_uuid) as uow:
return await self.resolve_account_workspace(
account_uuid,
normalized_workspace_uuid,
session=uow.session,
)
async def operation(active_session: AsyncSession) -> ResolvedWorkspaceAccess:
workspace_uuid = normalized_workspace_uuid
if workspace_uuid is None:
if self.policy.multi_workspace_enabled:
raise WorkspaceNotFoundError('A Workspace selector is required')
workspace = await self.workspace_service.get_singleton_workspace(session=active_session)
else:
workspace = await active_session.get(Workspace, workspace_uuid)
if (
workspace is None
or workspace.instance_uuid != self.workspace_service.instance_uuid
or workspace.status != WorkspaceStatus.ACTIVE.value
):
raise WorkspaceNotFoundError('Workspace not found')
membership = await active_session.scalar(
sqlalchemy.select(WorkspaceMembership).where(
WorkspaceMembership.workspace_uuid == workspace.uuid,
WorkspaceMembership.account_uuid == account_uuid,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
)
)
if membership is None:
# Deliberately hide Workspace existence across Accounts.
raise WorkspaceNotFoundError('Workspace not found')
execution = await self.workspace_service.get_execution_binding(
workspace.uuid,
session=active_session,
)
return ResolvedWorkspaceAccess(workspace, membership, execution)
return await self._run(operation, session=session, read_only=True)
async def list_account_workspaces(
self,
account_uuid: str,
*,
session: AsyncSession | None = None,
) -> list[ResolvedWorkspaceAccess]:
current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
account_uow = getattr(self.ap.persistence_mgr, 'account_discovery_uow', None)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if session is None and current_session() is None and callable(account_uow) and callable(tenant_uow):
# Discovery exposes only this Account's active Membership rows.
# Each resulting Workspace is then re-read under its own tenant
# transaction; directory discovery never grants business access.
async with account_uow(account_uuid) as discovery:
workspace_uuids = list(
(
await discovery.session.scalars(
sqlalchemy.select(WorkspaceMembership.workspace_uuid)
.where(
WorkspaceMembership.account_uuid == account_uuid,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
)
.order_by(WorkspaceMembership.workspace_uuid)
)
).all()
)
accesses: list[ResolvedWorkspaceAccess] = []
for workspace_uuid in workspace_uuids:
async with tenant_uow(workspace_uuid) as workspace_uow:
try:
accesses.append(
await self.resolve_account_workspace(
account_uuid,
workspace_uuid,
session=workspace_uow.session,
)
)
except (
WorkspaceExecutionUnavailableError,
WorkspaceInvariantError,
WorkspaceNotFoundError,
) as exc:
self.ap.logger.warning(
f'Skipping inactive Workspace discovery projection {workspace_uuid!r}: {exc}'
)
accesses.sort(key=lambda access: (access.workspace.created_at, access.workspace.uuid))
return accesses
async def operation(active_session: AsyncSession) -> list[ResolvedWorkspaceAccess]:
statement = (
sqlalchemy.select(WorkspaceMembership, Workspace)
.join(Workspace, Workspace.uuid == WorkspaceMembership.workspace_uuid)
.where(
WorkspaceMembership.account_uuid == account_uuid,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
Workspace.instance_uuid == self.workspace_service.instance_uuid,
Workspace.status == WorkspaceStatus.ACTIVE.value,
)
.order_by(Workspace.created_at, Workspace.uuid)
)
rows = (await active_session.execute(statement)).all()
accesses: list[ResolvedWorkspaceAccess] = []
for membership, workspace in rows:
execution = await self.workspace_service.get_execution_binding(
workspace.uuid,
session=active_session,
)
accesses.append(ResolvedWorkspaceAccess(workspace, membership, execution))
return accesses
return await self._run(operation, session=session, read_only=True)
async def list_members(
self,
workspace_uuid: str,
actor: WorkspaceMembership,
*,
session: AsyncSession | None = None,
) -> list[WorkspaceMemberView]:
if session is None:
current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)()
tenant_uow: typing.Any = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if current_session is None and callable(tenant_uow):
async with tenant_uow(workspace_uuid) as workspace_uow:
return await self.list_members(
workspace_uuid,
actor,
session=workspace_uow.session,
)
async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
await self._load_actor(active_session, workspace_uuid, actor)
statement = (
sqlalchemy.select(WorkspaceMembership, User.user)
.join(User, User.uuid == WorkspaceMembership.account_uuid)
.where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
User.status == AccountStatus.ACTIVE.value,
)
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
)
return [
WorkspaceMemberView(membership=membership, email=email)
for membership, email in (await active_session.execute(statement)).all()
]
return await self._run(operation, session=session, read_only=True)
async def list_invitations(
self,
workspace_uuid: str,
actor: WorkspaceMembership,
*,
session: AsyncSession | None = None,
) -> list[WorkspaceInvitation]:
async def operation(active_session: AsyncSession) -> list[WorkspaceInvitation]:
await self._require_active_workspace(active_session, workspace_uuid)
persisted_actor = await self._load_actor(active_session, workspace_uuid, actor)
self._require_member_manager(persisted_actor, workspace_uuid)
await self._expire_pending_invitations(active_session, workspace_uuid=workspace_uuid)
statement = (
sqlalchemy.select(WorkspaceInvitation)
.where(
WorkspaceInvitation.workspace_uuid == workspace_uuid,
WorkspaceInvitation.status == InvitationStatus.PENDING.value,
)
.order_by(WorkspaceInvitation.created_at, WorkspaceInvitation.uuid)
)
return list((await active_session.scalars(statement)).all())
return await self._run(operation, session=session)
async def create_invitation(
self,
workspace_uuid: str,
actor: WorkspaceMembership,
email: str,
role: str,
*,
expires_in: datetime.timedelta = datetime.timedelta(days=7),
session: AsyncSession | None = None,
) -> CreatedInvitation:
if role not in {
MembershipRole.ADMIN.value,
MembershipRole.DEVELOPER.value,
MembershipRole.OPERATOR.value,
MembershipRole.VIEWER.value,
}:
raise InvitationRoleError('Invitations cannot grant this role')
normalized_email = normalize_email(email)
if expires_in <= datetime.timedelta(0):
raise InvitationError('Invitation expiry must be in the future')
async def operation(active_session: AsyncSession) -> CreatedInvitation:
await self._require_active_workspace(active_session, workspace_uuid)
persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
self._require_member_manager(persisted_actor, workspace_uuid)
existing_account = await active_session.scalar(
sqlalchemy.select(User).where(User.normalized_email == normalized_email)
)
if existing_account is not None:
existing_membership = await active_session.scalar(
sqlalchemy.select(WorkspaceMembership).where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
WorkspaceMembership.account_uuid == existing_account.uuid,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
)
)
if existing_membership is not None:
raise AlreadyMemberError('This Account is already a Workspace member')
existing_pending = await active_session.scalar(
sqlalchemy.select(WorkspaceInvitation)
.where(
WorkspaceInvitation.workspace_uuid == workspace_uuid,
WorkspaceInvitation.normalized_email == normalized_email,
WorkspaceInvitation.status == InvitationStatus.PENDING.value,
)
.with_for_update()
)
now = self._utcnow()
if existing_pending is not None:
existing_pending.status = InvitationStatus.REVOKED.value
existing_pending.revoked_at = now
await active_session.flush()
token = f'lbi_{secrets.token_urlsafe(32)}'
invitation = WorkspaceInvitation(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_uuid,
normalized_email=normalized_email,
role=role,
token_hash=hash_invitation_token(token),
status=InvitationStatus.PENDING.value,
expires_at=now + expires_in,
created_by_account_uuid=persisted_actor.account_uuid,
)
active_session.add(invitation)
await active_session.flush()
return CreatedInvitation(invitation, token)
return await self._run(operation, session=session)
async def inspect_invitation(
self,
token: str,
*,
session: AsyncSession | None = None,
) -> tuple[WorkspaceInvitation, Workspace]:
if session is None:
scoped_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)()
invitation_uow = getattr(self.ap.persistence_mgr, 'invitation_discovery_uow', None)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if scoped_session is None and callable(invitation_uow) and callable(tenant_uow):
token_hash = hash_invitation_token(token)
async with invitation_uow(token_hash) as discovery:
invitation = await self._get_invitation_by_token(discovery.session, token, for_update=False)
workspace_uuid = invitation.workspace_uuid
async with tenant_uow(workspace_uuid) as workspace_uow:
return await self.inspect_invitation(token, session=workspace_uow.session)
async def operation(active_session: AsyncSession) -> tuple[WorkspaceInvitation, Workspace]:
invitation = await self._get_invitation_by_token(active_session, token, for_update=True)
self._validate_invitation_state(invitation)
workspace = await active_session.get(Workspace, invitation.workspace_uuid)
if workspace is None or workspace.status != WorkspaceStatus.ACTIVE.value:
raise InvitationError('The invitation Workspace is unavailable')
return invitation, workspace
return await self._run(operation, session=session)
async def accept_invitation(
self,
token: str,
account_uuid: str,
*,
session: AsyncSession | None = None,
) -> WorkspaceMembership:
if session is None:
scoped_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)()
invitation_uow = getattr(self.ap.persistence_mgr, 'invitation_discovery_uow', None)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if scoped_session is None and callable(invitation_uow) and callable(tenant_uow):
token_digest = hash_invitation_token(token)
async with invitation_uow(token_digest) as discovery:
invitation = await self._get_invitation_by_token(discovery.session, token, for_update=False)
workspace_uuid = invitation.workspace_uuid
async with tenant_uow(workspace_uuid) as workspace_uow:
return await self.accept_invitation(token, account_uuid, session=workspace_uow.session)
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
invitation = await self._get_invitation_by_token(active_session, token, for_update=True)
self._validate_invitation_state(invitation)
await self._require_active_workspace(active_session, invitation.workspace_uuid)
account = await active_session.scalar(sqlalchemy.select(User).where(User.uuid == account_uuid))
if account is None or account.status != AccountStatus.ACTIVE.value:
raise MembershipNotFoundError('Account not found')
if account.normalized_email != invitation.normalized_email:
raise InvitationEmailMismatchError('Invitation email does not match the Account')
membership = await active_session.scalar(
sqlalchemy.select(WorkspaceMembership)
.where(
WorkspaceMembership.workspace_uuid == invitation.workspace_uuid,
WorkspaceMembership.account_uuid == account_uuid,
)
.with_for_update()
)
now = self._utcnow()
if membership is None:
membership = WorkspaceMembership(
uuid=str(uuid.uuid4()),
workspace_uuid=invitation.workspace_uuid,
account_uuid=account_uuid,
role=invitation.role,
status=MembershipStatus.ACTIVE.value,
invited_by_account_uuid=invitation.created_by_account_uuid,
joined_at=now,
projection_revision=0,
)
active_session.add(membership)
elif membership.status != MembershipStatus.ACTIVE.value:
membership.role = invitation.role
membership.status = MembershipStatus.ACTIVE.value
membership.invited_by_account_uuid = invitation.created_by_account_uuid
membership.joined_at = now
invitation.status = InvitationStatus.ACCEPTED.value
invitation.accepted_at = now
await active_session.flush()
return membership
token_digest = hash_invitation_token(token)
async with self._invitation_lock(token_digest):
return await self._run(operation, session=session)
@asynccontextmanager
async def _invitation_lock(self, lock_key: str):
"""Serialize one token within workspace scope while retaining only active lock entries."""
async with self._invitation_locks_guard:
entry = self._invitation_locks.get(lock_key)
if entry is None:
entry = _InvitationLockEntry(lock=asyncio.Lock())
self._invitation_locks[lock_key] = entry
entry.users += 1
await entry.lock.acquire()
try:
yield
finally:
entry.lock.release()
async with self._invitation_locks_guard:
entry.users -= 1
if entry.users == 0:
self._invitation_locks.pop(lock_key, None)
async def revoke_invitation(
self,
workspace_uuid: str,
invitation_uuid: str,
actor: WorkspaceMembership,
*,
session: AsyncSession | None = None,
) -> WorkspaceInvitation:
async def operation(active_session: AsyncSession) -> WorkspaceInvitation:
await self._require_active_workspace(active_session, workspace_uuid)
persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
self._require_member_manager(persisted_actor, workspace_uuid)
invitation = await active_session.scalar(
sqlalchemy.select(WorkspaceInvitation)
.where(
WorkspaceInvitation.uuid == invitation_uuid,
WorkspaceInvitation.workspace_uuid == workspace_uuid,
)
.with_for_update()
)
if invitation is None:
raise InvitationError('Invitation not found')
if invitation.status == InvitationStatus.REVOKED.value:
return invitation
if invitation.status != InvitationStatus.PENDING.value:
self._validate_invitation_state(invitation)
invitation.status = InvitationStatus.REVOKED.value
invitation.revoked_at = self._utcnow()
await active_session.flush()
return invitation
return await self._run(operation, session=session)
async def cleanup_expired_invitations(
self,
*,
retention: datetime.timedelta = datetime.timedelta(0),
active_bindings: typing.Iterable[WorkspaceExecutionBinding] | None = None,
) -> int:
"""Delete expired invitation records without crossing Cloud tenant scopes."""
cutoff = self._utcnow() - retention
async def cleanup_session(active_session: AsyncSession, workspace_uuid: str | None = None) -> int:
statement = sqlalchemy.delete(WorkspaceInvitation).where(
WorkspaceInvitation.status.in_((InvitationStatus.PENDING.value, InvitationStatus.EXPIRED.value)),
WorkspaceInvitation.expires_at <= cutoff,
)
if workspace_uuid is not None:
statement = statement.where(WorkspaceInvitation.workspace_uuid == workspace_uuid)
result = await active_session.execute(statement)
return int(result.rowcount or 0)
if getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime':
list_bindings = getattr(self.workspace_service, 'list_active_execution_bindings', None)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if not callable(list_bindings) or not callable(tenant_uow):
raise RuntimeError('Cloud invitation cleanup requires tenant units of work')
deleted = 0
bindings = active_bindings if active_bindings is not None else await list_bindings()
for binding in bindings:
async with tenant_uow(binding.workspace_uuid) as uow:
deleted += await cleanup_session(uow.session, binding.workspace_uuid)
return deleted
return await self._run(cleanup_session, session=None)
async def run_expired_invitation_cleanup(self, *, interval_seconds: float = 3600) -> None:
"""Periodically remove expired records, waiting first so expiry inspection wins."""
while True:
await asyncio.sleep(interval_seconds)
try:
await self.cleanup_expired_invitations()
except asyncio.CancelledError:
raise
except Exception:
self.ap.logger.exception('Expired Workspace invitation cleanup failed')
async def update_member_role(
self,
workspace_uuid: str,
target_account_uuid: str,
role: str,
actor: WorkspaceMembership,
*,
session: AsyncSession | None = None,
) -> WorkspaceMembership:
if role not in {item.value for item in MembershipRole}:
raise MembershipPermissionError('Unknown Workspace role')
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
await self._require_active_workspace(active_session, workspace_uuid)
persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
self._require_member_manager(persisted_actor, workspace_uuid)
target = await self._get_active_member_for_update(
active_session,
workspace_uuid,
target_account_uuid,
)
self._require_can_manage_target(persisted_actor, target, new_role=role)
if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
target.role = role
await active_session.flush()
return target
return await self._run(operation, session=session)
async def remove_member(
self,
workspace_uuid: str,
target_account_uuid: str,
actor: WorkspaceMembership,
*,
session: AsyncSession | None = None,
) -> WorkspaceMembership:
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
await self._require_active_workspace(active_session, workspace_uuid)
persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
self._require_member_manager(persisted_actor, workspace_uuid)
target = await self._get_active_member_for_update(
active_session,
workspace_uuid,
target_account_uuid,
)
self._require_can_manage_target(persisted_actor, target)
if target.role == MembershipRole.OWNER.value:
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
target.status = MembershipStatus.REMOVED.value
await active_session.flush()
return target
return await self._run(operation, session=session)
async def _get_invitation_by_token(
self,
session: AsyncSession,
token: str,
*,
for_update: bool,
) -> WorkspaceInvitation:
if not isinstance(token, str) or not token.startswith('lbi_'):
raise InvitationError('Invitation not found')
statement = sqlalchemy.select(WorkspaceInvitation).where(
WorkspaceInvitation.token_hash == hash_invitation_token(token)
)
if for_update:
statement = statement.with_for_update()
invitation = await session.scalar(statement)
if invitation is None:
raise InvitationError('Invitation not found')
return invitation
async def _require_active_workspace(
self,
session: AsyncSession,
workspace_uuid: str,
) -> Workspace:
workspace = await session.get(Workspace, workspace_uuid)
if (
workspace is None
or workspace.instance_uuid != self.workspace_service.instance_uuid
or workspace.status != WorkspaceStatus.ACTIVE.value
):
raise WorkspaceNotFoundError('Workspace not found')
return workspace
def _validate_invitation_state(self, invitation: WorkspaceInvitation) -> None:
if invitation.status == InvitationStatus.REVOKED.value:
raise InvitationRevokedError('Invitation was revoked')
if invitation.status == InvitationStatus.ACCEPTED.value:
raise InvitationUsedError('Invitation was already accepted')
if invitation.status == InvitationStatus.EXPIRED.value or invitation.expires_at <= self._utcnow():
invitation.status = InvitationStatus.EXPIRED.value
raise InvitationExpiredError('Invitation has expired')
if invitation.status != InvitationStatus.PENDING.value:
raise InvitationError('Invitation is not pending')
async def _expire_pending_invitations(
self,
session: AsyncSession,
*,
workspace_uuid: str,
) -> None:
await session.execute(
sqlalchemy.update(WorkspaceInvitation)
.where(
WorkspaceInvitation.workspace_uuid == workspace_uuid,
WorkspaceInvitation.status == InvitationStatus.PENDING.value,
WorkspaceInvitation.expires_at <= self._utcnow(),
)
.values(status=InvitationStatus.EXPIRED.value)
)
async def _get_active_member_for_update(
self,
session: AsyncSession,
workspace_uuid: str,
account_uuid: str,
) -> WorkspaceMembership:
membership = await session.scalar(
sqlalchemy.select(WorkspaceMembership)
.where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
WorkspaceMembership.account_uuid == account_uuid,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
)
.with_for_update()
)
if membership is None:
raise MembershipNotFoundError('Workspace member not found')
return membership
async def _load_actor(
self,
session: AsyncSession,
workspace_uuid: str,
actor: WorkspaceMembership,
*,
for_update: bool = False,
) -> WorkspaceMembership:
self._require_actor_workspace(actor, workspace_uuid)
statement = sqlalchemy.select(WorkspaceMembership).where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
WorkspaceMembership.account_uuid == actor.account_uuid,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
)
if for_update:
statement = statement.with_for_update()
persisted_actor = await session.scalar(statement)
if persisted_actor is None:
raise WorkspaceNotFoundError('Workspace not found')
return persisted_actor
async def _require_another_owner(
self,
session: AsyncSession,
workspace_uuid: str,
excluded_account_uuid: str,
) -> None:
owners = (
await session.scalars(
sqlalchemy.select(WorkspaceMembership)
.where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
WorkspaceMembership.role == MembershipRole.OWNER.value,
)
.with_for_update()
)
).all()
if not any(owner.account_uuid != excluded_account_uuid for owner in owners):
raise LastOwnerError('The last Workspace owner cannot be removed or demoted')
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
raise WorkspaceNotFoundError('Workspace not found')
def _require_member_manager(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
self._require_actor_workspace(actor, workspace_uuid)
if actor.role not in {MembershipRole.OWNER.value, MembershipRole.ADMIN.value}:
raise MembershipPermissionError('Member management permission is required')
def _require_can_manage_target(
self,
actor: WorkspaceMembership,
target: WorkspaceMembership,
*,
new_role: str | None = None,
) -> None:
if actor.role == MembershipRole.ADMIN.value and (
target.role == MembershipRole.OWNER.value or new_role == MembershipRole.OWNER.value
):
raise MembershipPermissionError('Admins cannot manage Workspace owners')
@staticmethod
def _utcnow() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
async def _run(
self,
operation: Callable[[AsyncSession], Awaitable[T]],
*,
session: AsyncSession | None,
read_only: bool = False,
) -> T:
if session is not None:
return await operation(session)
current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)()
if current_session is not None:
return await operation(current_session)
if getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime':
require_current_session = getattr(self.ap.persistence_mgr, 'require_current_session', None)
if callable(require_current_session):
require_current_session()
raise RuntimeError('Cloud collaboration services require an explicit persistence unit of work')
async with self._session_factory()() as owned_session:
if read_only:
return await operation(owned_session)
async with owned_session.begin():
return await operation(owned_session)
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class WorkspaceExecutionBinding:
"""Core-neutral binding for one validated Workspace execution generation."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
write_fenced: bool
state: str
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
class WorkspaceError(Exception):
"""Base error for workspace directory operations."""
class WorkspaceNotFoundError(WorkspaceError):
"""Raised when the instance does not have its required local workspace."""
class WorkspaceInvariantError(WorkspaceError):
"""Raised when persisted workspace state violates a tenancy invariant."""
class WorkspaceLimitExceededError(WorkspaceError):
"""Raised when OSS code attempts to create a second local workspace."""
code = 'edition_limit'
class WorkspaceOwnerAlreadyExistsError(WorkspaceError):
"""Raised when another account already owns the singleton workspace."""
class WorkspaceExecutionUnavailableError(WorkspaceError):
"""Raised when a Workspace cannot accept work in its current execution state."""
class WorkspaceGenerationMismatchError(WorkspaceExecutionUnavailableError):
"""Raised when a caller holds a stale Workspace placement generation."""
@@ -0,0 +1,314 @@
from __future__ import annotations
import asyncio
import dataclasses
import os
import smtplib
import ssl
import typing
from email.message import EmailMessage
from urllib.parse import quote
import httpx
from ..utils import httpclient
if typing.TYPE_CHECKING:
from ..core.app import Application
DeliveryStatus = typing.Literal['sent', 'link_only', 'failed']
@dataclasses.dataclass(frozen=True, slots=True)
class InvitationDeliveryResult:
status: DeliveryStatus
provider: str | None
def to_public_dict(self) -> dict[str, str | None]:
return {'status': self.status, 'provider': self.provider}
@dataclasses.dataclass(frozen=True, slots=True)
class _EmailConfig:
provider: typing.Literal['resend', 'smtp'] | None
sender: str
resend_api_key: str
resend_api_url: str
smtp_host: str
smtp_port: int
smtp_username: str
smtp_password: str
smtp_starttls: bool
smtp_ssl: bool
timeout: float
class InvitationDeliveryService:
"""Optional Workspace invitation email delivery.
The invitation link is always returned to the caller. Email failures are
reported as non-secret status and never invalidate the persisted invite.
"""
def __init__(self, ap: Application) -> None:
self.ap = ap
def capability(self) -> dict[str, str | bool | None]:
config = self._email_config()
return {'enabled': config.provider is not None, 'provider': config.provider}
def build_invitation_link(self, token: str) -> str:
base_url = self._public_web_url().rstrip('/')
return f'{base_url}/invitations/accept#token={quote(token, safe="")}'
async def deliver_invitation(
self,
*,
recipient_email: str,
workspace_name: str,
invitation_link: str,
) -> InvitationDeliveryResult:
config = self._email_config()
if config.provider is None:
return InvitationDeliveryResult(status='link_only', provider=None)
try:
if config.provider == 'resend':
sent = await self._send_resend(config, recipient_email, workspace_name, invitation_link)
else:
sent = await self._send_smtp(config, recipient_email, workspace_name, invitation_link)
except Exception as exc:
self._log_delivery_failure(config.provider, exc)
sent = False
return InvitationDeliveryResult(
status='sent' if sent else 'failed',
provider=config.provider,
)
async def _send_resend(
self,
config: _EmailConfig,
recipient_email: str,
workspace_name: str,
invitation_link: str,
) -> bool:
payload = {
'from': config.sender,
'to': [recipient_email],
'subject': f'You were invited to {workspace_name}',
'text': self._plain_text(workspace_name, invitation_link),
'html': self._html(workspace_name, invitation_link),
}
async with httpx.AsyncClient(
timeout=httpx.Timeout(config.timeout),
trust_env=True,
event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
response = await client.post(
config.resend_api_url,
headers={'Authorization': f'Bearer {config.resend_api_key}'},
json=payload,
)
if response.status_code >= 400:
self._log_delivery_failure(
config.provider or 'resend', RuntimeError(f'Resend returned {response.status_code}')
)
return False
return True
async def _send_smtp(
self,
config: _EmailConfig,
recipient_email: str,
workspace_name: str,
invitation_link: str,
) -> bool:
message = EmailMessage()
message['From'] = config.sender
message['To'] = recipient_email
message['Subject'] = f'You were invited to {workspace_name}'
message.set_content(self._plain_text(workspace_name, invitation_link))
message.add_alternative(self._html(workspace_name, invitation_link), subtype='html')
return await asyncio.to_thread(self._send_smtp_sync, config, message)
@staticmethod
def _send_smtp_sync(config: _EmailConfig, message: EmailMessage) -> bool:
smtp_cls = smtplib.SMTP_SSL if config.smtp_ssl else smtplib.SMTP
context = ssl.create_default_context()
with smtp_cls(config.smtp_host, config.smtp_port, timeout=config.timeout) as smtp:
if config.smtp_starttls and not config.smtp_ssl:
smtp.starttls(context=context)
if config.smtp_username:
smtp.login(config.smtp_username, config.smtp_password)
smtp.send_message(message)
return True
def _email_config(self) -> _EmailConfig:
data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
email = data.get('workspace', {}).get('invitations', {}).get('email', {})
if not isinstance(email, dict):
email = {}
raw_provider = self._env('WORKSPACE__INVITATIONS__EMAIL__PROVIDER', email.get('provider', ''))
raw_provider = str(raw_provider or '').strip().casefold()
provider: typing.Literal['resend', 'smtp'] | None
provider = raw_provider if raw_provider in {'resend', 'smtp'} else None
sender = str(self._env('WORKSPACE__INVITATIONS__EMAIL__FROM', email.get('from', '')) or '').strip()
timeout = self._number(
self._env('WORKSPACE__INVITATIONS__EMAIL__TIMEOUT_SECONDS', email.get('timeout_seconds', 10)),
10.0,
)
resend = email.get('resend', {})
if not isinstance(resend, dict):
resend = {}
smtp_config = email.get('smtp', {})
if not isinstance(smtp_config, dict):
smtp_config = {}
resend_api_key = str(
self._env('WORKSPACE__INVITATIONS__EMAIL__RESEND__API_KEY', resend.get('api_key', '')) or ''
).strip()
resend_api_url = str(
self._env(
'WORKSPACE__INVITATIONS__EMAIL__RESEND__API_URL',
resend.get('api_url', 'https://api.resend.com/emails'),
)
or ''
).strip()
smtp_host = str(
self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__HOST', smtp_config.get('host', '')) or ''
).strip()
smtp_port = int(
self._number(self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__PORT', smtp_config.get('port', 587)), 587)
)
smtp_username = str(
self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__USERNAME', smtp_config.get('username', '')) or ''
).strip()
smtp_password = str(
self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD', smtp_config.get('password', '')) or ''
)
smtp_starttls = self._bool(
self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__STARTTLS', smtp_config.get('starttls', True))
)
smtp_ssl = self._bool(self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__SSL', smtp_config.get('ssl', False)))
if provider == 'resend' and not (sender and resend_api_key and resend_api_url):
provider = None
elif provider == 'smtp' and not (sender and smtp_host):
provider = None
return _EmailConfig(
provider=provider,
sender=sender,
resend_api_key=resend_api_key,
resend_api_url=resend_api_url,
smtp_host=smtp_host,
smtp_port=smtp_port,
smtp_username=smtp_username,
smtp_password=smtp_password,
smtp_starttls=smtp_starttls,
smtp_ssl=smtp_ssl,
timeout=timeout,
)
def _public_web_url(self) -> str:
data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
invitations = data.get('workspace', {}).get('invitations', {})
configured = ''
if isinstance(invitations, dict):
configured = str(
self._env('WORKSPACE__INVITATIONS__PUBLIC_WEB_URL', invitations.get('public_web_url', '')) or ''
).strip()
if configured:
return configured
api = data.get('api', {})
if isinstance(api, dict):
webui_url = str(api.get('webui_url', '') or '').strip()
if webui_url:
return webui_url
webhook_prefix = str(api.get('webhook_prefix', '') or '').strip()
if webhook_prefix:
return webhook_prefix
port = api.get('port', 5300)
else:
port = 5300
return f'http://127.0.0.1:{port}'
@staticmethod
def _plain_text(workspace_name: str, invitation_link: str) -> str:
return (
'You have been invited to LangBot Cloud\n\n'
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
f'Accept invitation: {invitation_link}\n\n'
'This secure invitation expires in 7 days and can only be accepted by the email address '
'it was sent to. If you were not expecting it, you can safely ignore this email.\n'
)
@staticmethod
def _html(workspace_name: str, invitation_link: str) -> str:
import html
escaped_workspace = html.escape(workspace_name, quote=True)
escaped_link = html.escape(invitation_link, quote=True)
return f'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Join {escaped_workspace} on LangBot Cloud</title>
</head>
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
<tr><td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">Youre invited</div>
</td></tr>
<tr><td style="padding:36px;">
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
</td></tr></table>
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>'''
@staticmethod
def _number(value: typing.Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
@staticmethod
def _bool(value: typing.Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {'true', '1', 'yes', 'on'}
return bool(value)
@staticmethod
def _env(name: str, fallback: typing.Any) -> typing.Any:
value = os.environ.get(name)
if value is None:
return fallback
return value
def _log_delivery_failure(self, provider: str, exc: Exception) -> None:
logger = getattr(self.ap, 'logger', None)
if logger is not None:
logger.warning(f'Workspace invitation email delivery via {provider} failed: {exc.__class__.__name__}')
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass
from .errors import WorkspaceLimitExceededError
@dataclass(frozen=True, slots=True)
class SingleWorkspacePolicy:
"""OSS edition policy: one local workspace with unrestricted membership count."""
workspace_limit: int = 1
members_enabled: bool = True
invitations_enabled: bool = True
fixed_rbac_enabled: bool = True
multi_workspace_enabled: bool = False
def require_workspace_creation_allowed(self, current_workspace_count: int) -> None:
if current_workspace_count >= self.workspace_limit:
raise WorkspaceLimitExceededError(f'This LangBot edition allows at most {self.workspace_limit} workspace')
@dataclass(frozen=True, slots=True)
class CloudWorkspacePolicy:
"""SaaS data-plane policy backed by closed control-plane projections.
Core never creates a cloud Workspace or mutates its directory. The policy
only enables explicit selection among already projected Workspaces.
"""
workspace_limit: int = 0
members_enabled: bool = True
invitations_enabled: bool = False
fixed_rbac_enabled: bool = True
multi_workspace_enabled: bool = True
def require_workspace_creation_allowed(self, current_workspace_count: int) -> None:
del current_workspace_count
raise WorkspaceLimitExceededError('Cloud Workspaces are created by the SaaS control plane')
def open_core_workspace_policy() -> SingleWorkspacePolicy:
"""Return the only policy the open-source bootstrap may activate.
``system.edition`` and other local configuration are deliberately absent
from this boundary. A future closed Cloud bootstrap must first verify its
signed InstanceManifest and then explicitly construct the cloud policy.
"""
return SingleWorkspacePolicy()
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncSession
from ..entity.persistence.workspace import (
MembershipRole,
MembershipStatus,
Workspace,
WorkspaceExecutionState,
WorkspaceMembership,
WorkspaceSource,
)
class WorkspaceRepository:
"""Transaction-bound persistence operations for the workspace directory."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def count_local_workspaces(self, instance_uuid: str) -> int:
statement = (
sqlalchemy.select(sqlalchemy.func.count())
.select_from(Workspace)
.where(
Workspace.instance_uuid == instance_uuid,
Workspace.source == WorkspaceSource.LOCAL.value,
)
)
return int((await self.session.scalar(statement)) or 0)
async def list_local_workspaces(self, instance_uuid: str, *, for_update: bool = False) -> list[Workspace]:
statement = (
sqlalchemy.select(Workspace)
.where(
Workspace.instance_uuid == instance_uuid,
Workspace.source == WorkspaceSource.LOCAL.value,
)
.order_by(Workspace.created_at, Workspace.uuid)
)
if for_update:
statement = statement.with_for_update()
return list((await self.session.scalars(statement)).all())
async def get_workspace(self, workspace_uuid: str) -> Workspace | None:
return await self.session.get(Workspace, workspace_uuid)
def add_workspace(self, workspace: Workspace) -> None:
self.session.add(workspace)
async def get_execution_state(self, workspace_uuid: str) -> WorkspaceExecutionState | None:
return await self.session.get(WorkspaceExecutionState, workspace_uuid)
def add_execution_state(self, execution_state: WorkspaceExecutionState) -> None:
self.session.add(execution_state)
async def get_membership(self, workspace_uuid: str, account_uuid: str) -> WorkspaceMembership | None:
statement = sqlalchemy.select(WorkspaceMembership).where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
WorkspaceMembership.account_uuid == account_uuid,
)
return await self.session.scalar(statement)
async def get_active_owner(self, workspace_uuid: str, *, for_update: bool = False) -> WorkspaceMembership | None:
statement = (
sqlalchemy.select(WorkspaceMembership)
.where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
WorkspaceMembership.role == MembershipRole.OWNER.value,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
)
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
.limit(1)
)
if for_update:
statement = statement.with_for_update()
return await self.session.scalar(statement)
def add_membership(self, membership: WorkspaceMembership) -> None:
self.session.add(membership)
async def flush(self) -> None:
await self.session.flush()
+555
View File
@@ -0,0 +1,555 @@
from __future__ import annotations
import datetime
import uuid
import typing
from collections.abc import Awaitable, Callable
from typing import TypeVar
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ..entity.persistence.workspace import (
MembershipRole,
MembershipStatus,
Workspace,
WorkspaceExecutionSource,
WorkspaceExecutionState,
WorkspaceExecutionStatus,
WorkspaceMembership,
WorkspaceSource,
WorkspaceStatus,
WorkspaceType,
)
from ..utils import constants
from .errors import (
WorkspaceExecutionUnavailableError,
WorkspaceGenerationMismatchError,
WorkspaceInvariantError,
WorkspaceNotFoundError,
WorkspaceOwnerAlreadyExistsError,
)
from .entities import WorkspaceExecutionBinding
from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
from .repository import WorkspaceRepository
T = TypeVar('T')
if typing.TYPE_CHECKING:
from ..core.app import Application
class WorkspaceService:
"""Local workspace lifecycle service used by OSS bootstrap and account flows."""
def __init__(
self,
ap: Application,
*,
policy: SingleWorkspacePolicy | CloudWorkspacePolicy | None = None,
instance_uuid: str | None = None,
) -> None:
self.ap = ap
self.policy = policy or SingleWorkspacePolicy()
self._instance_uuid = instance_uuid
self._startup_execution_bindings: (
tuple[
WorkspaceExecutionBinding,
...,
]
| None
) = None
@property
def instance_uuid(self) -> str:
instance_uuid = (self._instance_uuid or constants.instance_id).strip()
if not instance_uuid:
raise WorkspaceInvariantError('LangBot instance UUID is empty')
return instance_uuid
async def get_workspace(
self,
workspace_uuid: str,
*,
session: AsyncSession | None = None,
) -> Workspace:
"""Load one Workspace projected onto this LangBot instance."""
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if session is None and callable(tenant_uow):
async with tenant_uow(workspace_uuid) as uow:
return await self.get_workspace(workspace_uuid, session=uow.session)
async def operation(repository: WorkspaceRepository) -> Workspace:
workspace = await repository.get_workspace(workspace_uuid)
if workspace is None or workspace.instance_uuid != self.instance_uuid:
raise WorkspaceNotFoundError('Workspace not found')
return workspace
return await self._run(operation, session=session)
async def get_singleton_workspace(self, *, session: AsyncSession | None = None) -> Workspace:
async def operation(repository: WorkspaceRepository) -> Workspace:
workspaces = await repository.list_local_workspaces(self.instance_uuid)
if not workspaces:
raise WorkspaceNotFoundError('The local workspace has not been initialized')
if len(workspaces) != 1:
raise WorkspaceInvariantError(
f'Expected one local workspace for {self.instance_uuid!r}, found {len(workspaces)}'
)
return workspaces[0]
return await self._run(operation, session=session)
async def get_execution_state(
self,
workspace_uuid: str,
*,
session: AsyncSession | None = None,
) -> WorkspaceExecutionState:
"""Load a Workspace execution state and validate its instance binding."""
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if session is None and callable(tenant_uow):
async with tenant_uow(workspace_uuid) as uow:
return await self.get_execution_state(workspace_uuid, session=uow.session)
async def operation(repository: WorkspaceRepository) -> WorkspaceExecutionState:
execution_state = await repository.get_execution_state(workspace_uuid)
if execution_state is None:
raise WorkspaceExecutionUnavailableError(f'Workspace {workspace_uuid!r} has no execution state')
if execution_state.instance_uuid != self.instance_uuid:
raise WorkspaceInvariantError(
f'Workspace {workspace_uuid!r} execution state belongs to another instance'
)
return execution_state
return await self._run(operation, session=session)
async def list_active_execution_bindings(self) -> list[WorkspaceExecutionBinding]:
"""Discover this instance's active Workspaces, then validate each tenant projection.
The instance discovery transaction may only reveal active, unfenced
execution-state identifiers. It is closed before any Workspace data is
read; every returned binding is revalidated inside its own tenant unit
of work.
"""
self._require_deployment_admission()
self._require_directory_projection()
if self._startup_execution_bindings is not None:
return list(self._startup_execution_bindings)
return await self._discover_active_execution_bindings()
async def prime_startup_execution_bindings(
self,
) -> list[WorkspaceExecutionBinding]:
"""Freeze one validated binding snapshot during the serial boot graph.
Directory synchronization starts only after ``BuildAppStage`` has
finished, so all runtime managers would otherwise rediscover and
revalidate the same active Workspace set independently. A boot-scoped
immutable snapshot removes those repeated tenant transactions without
weakening request-time generation checks.
"""
self._require_deployment_admission()
self._require_directory_projection()
if self._startup_execution_bindings is None:
self._startup_execution_bindings = tuple(await self._discover_active_execution_bindings())
return list(self._startup_execution_bindings)
def release_startup_execution_bindings(self) -> None:
"""Release the boot-only projection snapshot before background sync."""
self._startup_execution_bindings = None
async def _discover_active_execution_bindings(
self,
) -> list[WorkspaceExecutionBinding]:
instance_discovery_uow = getattr(self.ap.persistence_mgr, 'instance_discovery_uow', None)
statement = (
sqlalchemy.select(WorkspaceExecutionState.workspace_uuid)
.where(
WorkspaceExecutionState.instance_uuid == self.instance_uuid,
WorkspaceExecutionState.state == WorkspaceExecutionStatus.ACTIVE.value,
WorkspaceExecutionState.write_fenced == sqlalchemy.false(),
)
.order_by(WorkspaceExecutionState.workspace_uuid)
)
if callable(instance_discovery_uow):
async with instance_discovery_uow(self.instance_uuid) as uow:
result = await uow.session.execute(statement)
workspace_uuids = list(result.scalars().all())
else:
# Compatibility for lightweight test doubles. Production managers
# always expose the explicit discovery scope.
result = await self.ap.persistence_mgr.execute_async(statement)
workspace_uuids = list(result.scalars().all())
bindings: list[WorkspaceExecutionBinding] = []
for workspace_uuid in workspace_uuids:
try:
bindings.append(await self.get_execution_binding(workspace_uuid))
except (
WorkspaceExecutionUnavailableError,
WorkspaceInvariantError,
WorkspaceNotFoundError,
) as exc:
self.ap.logger.warning(f'Skipping invalid Workspace execution projection {workspace_uuid!r}: {exc}')
return bindings
async def get_execution_binding(
self,
workspace_uuid: str | None = None,
*,
expected_generation: int | None = None,
session: AsyncSession | None = None,
_require_local: bool = False,
) -> WorkspaceExecutionBinding:
"""Resolve an active, unfenced binding projected onto this instance.
SaaS Workspaces are projected by a closed control plane, but Core still
validates the local projection and execution fence. Callers never infer
a Workspace from source or recency.
"""
self._require_deployment_admission()
self._require_directory_projection()
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if session is None and workspace_uuid is not None and callable(tenant_uow):
async with tenant_uow(workspace_uuid) as uow:
return await self.get_execution_binding(
workspace_uuid,
expected_generation=expected_generation,
session=uow.session,
_require_local=_require_local,
)
async def operation(repository: WorkspaceRepository) -> WorkspaceExecutionBinding:
if workspace_uuid is None:
workspaces = await repository.list_local_workspaces(self.instance_uuid)
if not workspaces:
raise WorkspaceNotFoundError('The local workspace has not been initialized')
if len(workspaces) != 1:
raise WorkspaceInvariantError(
f'Expected one local workspace for {self.instance_uuid!r}, found {len(workspaces)}'
)
workspace = workspaces[0]
else:
workspace = await repository.get_workspace(workspace_uuid)
if workspace is None:
raise WorkspaceNotFoundError(f'Workspace {workspace_uuid!r} does not exist')
if workspace.instance_uuid != self.instance_uuid:
raise WorkspaceInvariantError(f'Workspace {workspace.uuid!r} belongs to another instance')
if _require_local and workspace.source != WorkspaceSource.LOCAL.value:
raise WorkspaceInvariantError(f'Workspace {workspace.uuid!r} is not an OSS local workspace')
if workspace.status != WorkspaceStatus.ACTIVE.value:
raise WorkspaceExecutionUnavailableError(f'Workspace {workspace.uuid!r} is not active')
execution_state = await repository.get_execution_state(workspace.uuid)
if execution_state is None:
raise WorkspaceExecutionUnavailableError(f'Workspace {workspace.uuid!r} has no execution state')
if execution_state.instance_uuid != self.instance_uuid:
raise WorkspaceInvariantError(
f'Workspace {workspace.uuid!r} execution state belongs to another instance'
)
expected_source = (
WorkspaceExecutionSource.LOCAL.value
if workspace.source == WorkspaceSource.LOCAL.value
else WorkspaceExecutionSource.CLOUD.value
)
if execution_state.source != expected_source:
raise WorkspaceInvariantError(
f'Workspace {workspace.uuid!r} execution source does not match its directory source'
)
if execution_state.state != WorkspaceExecutionStatus.ACTIVE.value or execution_state.write_fenced:
raise WorkspaceExecutionUnavailableError(f'Workspace {workspace.uuid!r} execution is unavailable')
if execution_state.active_generation <= 0:
raise WorkspaceInvariantError(f'Workspace {workspace.uuid!r} has an invalid execution generation')
if expected_generation is not None and execution_state.active_generation != expected_generation:
raise WorkspaceGenerationMismatchError(
f'Workspace {workspace.uuid!r} generation {execution_state.active_generation} '
f'does not match expected generation {expected_generation}'
)
return WorkspaceExecutionBinding(
instance_uuid=self.instance_uuid,
workspace_uuid=workspace.uuid,
placement_generation=execution_state.active_generation,
write_fenced=execution_state.write_fenced,
state=execution_state.state,
)
binding = await self._run(operation, session=session)
# The database lookup can cross the Manifest expiry boundary. Never
# return a binding that is already invalid at a side-effect boundary.
self._require_directory_projection()
self._require_deployment_admission()
return binding
async def get_local_execution_binding(
self,
workspace_uuid: str | None = None,
*,
expected_generation: int | None = None,
session: AsyncSession | None = None,
) -> WorkspaceExecutionBinding:
"""Resolve an active binding and require an OSS-local Workspace."""
return await self.get_execution_binding(
workspace_uuid,
expected_generation=expected_generation,
session=session,
_require_local=True,
)
async def get_local_execution_context(
self,
workspace_uuid: str | None = None,
*,
expected_generation: int | None = None,
session: AsyncSession | None = None,
) -> WorkspaceExecutionBinding:
"""Compatibility alias for callers introduced during the tenancy rollout."""
return await self.get_local_execution_binding(
workspace_uuid,
expected_generation=expected_generation,
session=session,
)
async def ensure_singleton_workspace(
self,
*,
session: AsyncSession | None = None,
name: str = 'Default Workspace',
slug: str = 'default',
) -> Workspace:
"""Create or repair the instance's single local workspace and execution state."""
async def operation(repository: WorkspaceRepository) -> Workspace:
workspaces = await repository.list_local_workspaces(self.instance_uuid, for_update=True)
if len(workspaces) > 1:
raise WorkspaceInvariantError(f'Multiple local workspaces exist for instance {self.instance_uuid!r}')
if workspaces:
workspace = workspaces[0]
else:
self.policy.require_workspace_creation_allowed(0)
workspace = self._new_local_workspace(name=name, slug=slug)
repository.add_workspace(workspace)
await repository.flush()
await self._ensure_execution_state(repository, workspace)
return workspace
return await self._run(operation, session=session)
async def create_local_workspace(
self,
*,
name: str,
slug: str,
created_by_account_uuid: str | None = None,
session: AsyncSession | None = None,
) -> Workspace:
"""Create the OSS workspace, enforcing the one-workspace edition limit."""
async def operation(repository: WorkspaceRepository) -> Workspace:
current_count = await repository.count_local_workspaces(self.instance_uuid)
self.policy.require_workspace_creation_allowed(current_count)
workspace = self._new_local_workspace(
name=name,
slug=slug,
created_by_account_uuid=created_by_account_uuid,
)
repository.add_workspace(workspace)
await repository.flush()
await self._ensure_execution_state(repository, workspace)
if created_by_account_uuid is not None:
await self._claim_initial_owner(repository, workspace, created_by_account_uuid)
return workspace
return await self._run(operation, session=session)
async def claim_initial_owner(
self,
account_uuid: str,
*,
session: AsyncSession | None = None,
) -> WorkspaceMembership:
"""Atomically claim an ownerless singleton workspace for the first account."""
async def operation(repository: WorkspaceRepository) -> WorkspaceMembership:
workspaces = await repository.list_local_workspaces(self.instance_uuid, for_update=True)
if not workspaces:
self.policy.require_workspace_creation_allowed(0)
workspace = self._new_local_workspace(name='Default Workspace', slug='default')
repository.add_workspace(workspace)
await repository.flush()
elif len(workspaces) == 1:
workspace = workspaces[0]
else:
raise WorkspaceInvariantError(f'Multiple local workspaces exist for instance {self.instance_uuid!r}')
await self._ensure_execution_state(repository, workspace)
return await self._claim_initial_owner(repository, workspace, account_uuid)
return await self._run(operation, session=session)
async def bootstrap_local_account(
self,
account_uuid: str,
*,
session: AsyncSession | None = None,
) -> tuple[Workspace, WorkspaceMembership]:
"""Bind the first local account to the singleton workspace as owner."""
async def operation(repository: WorkspaceRepository) -> tuple[Workspace, WorkspaceMembership]:
workspaces = await repository.list_local_workspaces(self.instance_uuid, for_update=True)
if not workspaces:
self.policy.require_workspace_creation_allowed(0)
workspace = self._new_local_workspace(name='Default Workspace', slug='default')
repository.add_workspace(workspace)
await repository.flush()
elif len(workspaces) == 1:
workspace = workspaces[0]
else:
raise WorkspaceInvariantError(f'Multiple local workspaces exist for instance {self.instance_uuid!r}')
await self._ensure_execution_state(repository, workspace)
membership = await self._claim_initial_owner(repository, workspace, account_uuid)
return workspace, membership
return await self._run(operation, session=session)
async def _claim_initial_owner(
self,
repository: WorkspaceRepository,
workspace: Workspace,
account_uuid: str,
) -> WorkspaceMembership:
active_owner = await repository.get_active_owner(workspace.uuid, for_update=True)
if active_owner is not None and active_owner.account_uuid != account_uuid:
raise WorkspaceOwnerAlreadyExistsError(f'Workspace {workspace.uuid!r} already has an owner')
if active_owner is not None:
if workspace.created_by_account_uuid is None:
workspace.created_by_account_uuid = account_uuid
await repository.flush()
return active_owner
membership = await repository.get_membership(workspace.uuid, account_uuid)
joined_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
if membership is None:
membership = WorkspaceMembership(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace.uuid,
account_uuid=account_uuid,
role=MembershipRole.OWNER.value,
status=MembershipStatus.ACTIVE.value,
joined_at=joined_at,
projection_revision=0,
)
repository.add_membership(membership)
else:
membership.role = MembershipRole.OWNER.value
membership.status = MembershipStatus.ACTIVE.value
membership.joined_at = membership.joined_at or joined_at
if workspace.created_by_account_uuid is None:
workspace.created_by_account_uuid = account_uuid
await repository.flush()
return membership
async def _ensure_execution_state(
self,
repository: WorkspaceRepository,
workspace: Workspace,
) -> WorkspaceExecutionState:
execution_state = await repository.get_execution_state(workspace.uuid)
if execution_state is not None:
if execution_state.instance_uuid != self.instance_uuid:
raise WorkspaceInvariantError(
f'Workspace {workspace.uuid!r} execution state belongs to another instance'
)
return execution_state
execution_state = WorkspaceExecutionState(
workspace_uuid=workspace.uuid,
instance_uuid=self.instance_uuid,
active_generation=1,
state=WorkspaceExecutionStatus.ACTIVE.value,
write_fenced=False,
source=WorkspaceExecutionSource.LOCAL.value,
desired_state_revision=0,
)
repository.add_execution_state(execution_state)
await repository.flush()
return execution_state
def _new_local_workspace(
self,
*,
name: str,
slug: str,
created_by_account_uuid: str | None = None,
) -> Workspace:
return Workspace(
uuid=str(uuid.uuid4()),
instance_uuid=self.instance_uuid,
name=name,
slug=slug,
type=WorkspaceType.TEAM.value,
status=WorkspaceStatus.ACTIVE.value,
created_by_account_uuid=created_by_account_uuid,
source=WorkspaceSource.LOCAL.value,
projection_revision=0,
)
async def _run(
self,
operation: Callable[[WorkspaceRepository], Awaitable[T]],
*,
session: AsyncSession | None,
) -> T:
if session is not None:
return await operation(WorkspaceRepository(session))
current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
active_session = current_session()
if active_session is not None:
return await operation(WorkspaceRepository(active_session))
if getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime':
# Do not let service-local session factories silently bypass the
# request/task scope enforced by PersistenceManager.
require_current_session = getattr(self.ap.persistence_mgr, 'require_current_session', None)
if callable(require_current_session):
require_current_session()
raise RuntimeError('Cloud Workspace services require an explicit persistence unit of work')
session_factory = async_sessionmaker(
self.ap.persistence_mgr.get_db_engine(),
expire_on_commit=False,
)
async with session_factory() as owned_session:
async with owned_session.begin():
return await operation(WorkspaceRepository(owned_session))
def _require_deployment_admission(self) -> None:
guard = getattr(self.ap, 'deployment_admission', None)
if guard is not None:
guard.require_active()
def _require_directory_projection(self) -> None:
deployment = getattr(self.ap, 'deployment', None)
if deployment is None or not getattr(deployment, 'multi_workspace_enabled', False):
return
projection = getattr(self.ap, 'directory_projection_service', None)
if projection is None:
raise WorkspaceExecutionUnavailableError('Cloud directory projection is unavailable')
try:
projection.require_ready()
except RuntimeError as exc:
raise WorkspaceExecutionUnavailableError(str(exc)) from exc