mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-23 10:37:13 +00:00
feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,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',
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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;">You’re 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__}')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user