feat(workspace): add in-product collaboration and direct Cloud launch

This commit is contained in:
dadachann
2026-07-26 10:40:36 +08:00
parent 276791e7af
commit 8a445bfb22
29 changed files with 1258 additions and 124 deletions
@@ -9,6 +9,7 @@ from .....entity.persistence.metadata import WorkspaceMetadata
from ...authz import Permission
from ...context import RequestContext
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
from .....workspace.invitation_delivery import InvitationDeliveryService
@group.group_class('system', '/api/v1/system')
@@ -75,6 +76,10 @@ class SystemRouterGroup(group.RouterGroup):
else:
outbound_ips = []
invitation_delivery_service = getattr(self.ap, 'invitation_delivery_service', None)
if invitation_delivery_service is None:
invitation_delivery_service = InvitationDeliveryService(self.ap)
return self.success(
data={
'version': constants.semantic_version,
@@ -97,6 +102,7 @@ class SystemRouterGroup(group.RouterGroup):
'mcp_stdio_enabled': stdio_mcp_enabled(self.ap),
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
'outbound_ips': outbound_ips,
'invitation_delivery': invitation_delivery_service.capability(),
'wizard_status': wizard_status,
'wizard_progress': wizard_progress,
}
@@ -1,11 +1,13 @@
import quart
import argon2
import asyncio
import uuid
from urllib.parse import parse_qs, urlsplit
from .. import group
from .....entity.errors import account as account_errors
from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
@@ -153,7 +155,20 @@ class UserRouterGroup(group.RouterGroup):
try:
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
state = await self.ap.user_service.issue_space_oauth_state('login')
launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid')
if launch_workspace_uuid:
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
return self.fail(1, 'Space launch requires Cloud mode')
try:
uuid.UUID(launch_workspace_uuid)
except ValueError:
return self.fail(1, 'Invalid launch Workspace')
state = await self.ap.user_service.issue_space_oauth_state(
'login',
launch_workspace_uuid=launch_workspace_uuid,
)
else:
state = await self.ap.user_service.issue_space_oauth_state('login')
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
except ValueError as e:
@@ -184,6 +199,14 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state')
launch_assertion = json_data.get('launch_assertion')
workspace_uuid = json_data.get('workspace_uuid')
if launch_assertion:
return await self._handle_space_direct_launch(
str(launch_assertion),
str(workspace_uuid or '') or None,
)
if not code:
return self.fail(1, 'Missing authorization code')
@@ -191,7 +214,7 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(1, 'Missing state parameter')
try:
await self.ap.user_service.consume_space_oauth_state(state, 'login')
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
@@ -206,6 +229,24 @@ class UserRouterGroup(group.RouterGroup):
access_token, refresh_token, expires_in
)
launch_workspace_uuid = consumed_state.launch_workspace_uuid
if launch_workspace_uuid:
try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
user_obj.uuid,
launch_workspace_uuid,
)
except Exception:
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
return self.fail(1, 'Space OAuth failed')
return self.success(
data={
'token': jwt_token,
'user': user_obj.user,
'workspace_uuid': access.workspace.uuid,
}
)
return self.success(
data={
'token': jwt_token,
@@ -331,3 +372,36 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Space account binding failed')
except Exception:
raise
async def _handle_space_direct_launch(
self,
launch_assertion: str,
workspace_uuid: str | None,
) -> str:
try:
launch = await self.ap.space_launch_service.consume_assertion(
launch_assertion,
expected_workspace_uuid=workspace_uuid,
)
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core')
self.ap.user_service._require_active_account(account)
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
account.uuid,
launch['workspace_uuid'],
)
token = await self.ap.user_service.generate_jwt_token(account)
return self.success(
data={
'token': token,
'user': account.user,
'workspace_uuid': access.workspace.uuid,
}
)
except SpaceLaunchError:
self.ap.logger.warning('Rejected Space direct-launch assertion')
return self.fail(1, 'Space launch failed')
except Exception:
self.ap.logger.exception('Space direct launch failed')
return self.fail(1, 'Space launch failed')
@@ -11,6 +11,7 @@ from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, Wo
from .....entity.persistence.workspace import WorkspaceSource
from .....workspace.collaboration import WorkspaceMemberView
from .....workspace.errors import WorkspaceNotFoundError
from .....workspace.invitation_delivery import InvitationDeliveryService
from .. import group
@@ -153,8 +154,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
)
async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
if await self._requires_control_plane(workspace_uuid):
return self._control_plane_required()
if quart.request.method == 'GET':
invitations = await self.ap.workspace_collaboration_service.list_invitations(
workspace_uuid,
@@ -169,10 +168,20 @@ class WorkspacesRouterGroup(group.RouterGroup):
str(data.get('email', '')),
str(data.get('role', 'viewer')),
)
delivery_service = self._invitation_delivery_service()
link = delivery_service.build_invitation_link(created.token)
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
delivery = await delivery_service.deliver_invitation(
recipient_email=created.invitation.normalized_email,
workspace_name=workspace.name,
invitation_link=link,
)
return self.success(
data={
'invitation': _invitation_payload(created.invitation),
'token': created.token,
'link': link,
'delivery': delivery.to_public_dict(),
}
)
@@ -187,8 +196,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
request_context: RequestContext,
) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
if await self._requires_control_plane(workspace_uuid):
return self._control_plane_required()
invitation = await self.ap.workspace_collaboration_service.revoke_invitation(
workspace_uuid,
invitation_uuid,
@@ -207,8 +214,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
request_context: RequestContext,
) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
if await self._requires_control_plane(workspace_uuid):
return self._control_plane_required()
if quart.request.method == 'DELETE':
if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions:
return self.http_status(403, 'permission_denied', 'Member removal permission is required')
@@ -241,16 +246,12 @@ class WorkspacesRouterGroup(group.RouterGroup):
if workspace_uuid != request_context.workspace_uuid:
raise WorkspaceNotFoundError('Workspace not found')
async def _requires_control_plane(self, workspace_uuid: str) -> bool:
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
return workspace.source == WorkspaceSource.CLOUD_PROJECTION.value
def _control_plane_required(self) -> typing.Any:
return self.http_status(
409,
'control_plane_required',
'Cloud Workspace membership and invitations are managed by the SaaS control plane',
)
def _invitation_delivery_service(self) -> InvitationDeliveryService:
service = getattr(self.ap, 'invitation_delivery_service', None)
if service is None:
service = InvitationDeliveryService(self.ap)
self.ap.invitation_delivery_service = service
return service
@staticmethod
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
+31 -8
View File
@@ -6,6 +6,7 @@ import jwt
import datetime
import typing
import asyncio
import dataclasses
import hashlib
import secrets
import time
@@ -39,6 +40,13 @@ class AccountDisabledError(ValueError):
code = 'account_disabled'
@dataclasses.dataclass(frozen=True, slots=True)
class SpaceOAuthStateConsumption:
purpose: typing.Literal['login', 'bind']
account: user.User | None
launch_workspace_uuid: str | None = None
class UserService:
ap: Application
_create_user_lock: asyncio.Lock
@@ -48,7 +56,7 @@ class UserService:
self._create_user_lock = asyncio.Lock()
self._password_hash_lock = asyncio.Semaphore(1)
self._space_oauth_state_lock = asyncio.Lock()
self._space_oauth_states: dict[str, tuple[str, str | None, float]] = {}
self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
@staticmethod
def _space_oauth_state_digest(state: str) -> str:
@@ -59,6 +67,7 @@ class UserService:
purpose: typing.Literal['login', 'bind'],
*,
account_uuid: str | None = None,
launch_workspace_uuid: str | None = None,
ttl_seconds: int = 600,
) -> str:
"""Issue an opaque, single-use OAuth state without exposing a JWT."""
@@ -66,6 +75,8 @@ class UserService:
raise ValueError('An Account is required for Space binding')
if purpose == 'login' and account_uuid is not None:
raise ValueError('Login state cannot be bound to an Account')
if purpose != 'login' and launch_workspace_uuid is not None:
raise ValueError('Launch Workspace state is only valid for Space login')
if ttl_seconds <= 0:
raise ValueError('OAuth state lifetime must be positive')
@@ -78,15 +89,15 @@ class UserService:
if len(self._space_oauth_states) >= 4096:
oldest = min(self._space_oauth_states, key=lambda key: self._space_oauth_states[key][2])
self._space_oauth_states.pop(oldest, None)
self._space_oauth_states[digest] = (purpose, account_uuid, expires_at)
self._space_oauth_states[digest] = (purpose, account_uuid, expires_at, launch_workspace_uuid)
return raw_state
async def consume_space_oauth_state(
async def consume_space_oauth_state_details(
self,
raw_state: str,
purpose: typing.Literal['login', 'bind'],
) -> user.User | None:
"""Atomically consume OAuth state and resolve its active bind Account."""
) -> SpaceOAuthStateConsumption:
"""Atomically consume OAuth state and return any bound launch intent."""
if not isinstance(raw_state, str) or not raw_state:
raise ValueError('Invalid or expired OAuth state')
digest = self._space_oauth_state_digest(raw_state)
@@ -95,14 +106,27 @@ class UserService:
if entry is None or entry[0] != purpose or entry[2] <= time.monotonic():
raise ValueError('Invalid or expired OAuth state')
if purpose == 'login':
return None
return SpaceOAuthStateConsumption(
purpose='login',
account=None,
launch_workspace_uuid=entry[3],
)
account_uuid = entry[1]
account = await self.get_user_by_uuid(account_uuid or '')
if account is None:
raise ValueError('Invalid or expired OAuth state')
self._require_active_account(account)
return account
return SpaceOAuthStateConsumption(purpose='bind', account=account)
async def consume_space_oauth_state(
self,
raw_state: str,
purpose: typing.Literal['login', 'bind'],
) -> user.User | None:
"""Atomically consume OAuth state and resolve its active bind Account."""
consumed = await self.consume_space_oauth_state_details(raw_state, purpose)
return consumed.account
async def _hash_password(self, password: str) -> str:
async with self._password_hash_lock:
@@ -209,7 +233,6 @@ class UserService:
) -> tuple[user.User, typing.Any, str]:
"""Create an invited Account and accept its Membership in one transaction."""
self._require_local_directory()
normalized_email = normalize_email(user_email)
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
if invitation.normalized_email != normalized_email:
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
import asyncio
import base64
import binascii
import hashlib
import json
import os
import time
import typing
from collections.abc import Callable, Iterable
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
if typing.TYPE_CHECKING:
from ..core.app import Application
CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
LAUNCH_KIND = 'workspace.launch'
EXPECTED_ISSUER = 'langbot-space'
EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
class SpaceLaunchError(ValueError):
"""Raised when a Space-issued Cloud launch assertion is not admissible."""
def _decode_base64url(value: str, *, label: str) -> bytes:
if not value or any(character.isspace() for character in value):
raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url')
try:
raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True)
except (binascii.Error, ValueError) as exc:
raise SpaceLaunchError(f'Launch assertion {label} is not valid base64url') from exc
if base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') != value:
raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url')
return raw
def _strict_json_object(value: bytes, *, label: str) -> dict[str, typing.Any]:
def reject_duplicate_keys(pairs: Iterable[tuple[str, typing.Any]]) -> dict[str, typing.Any]:
result: dict[str, typing.Any] = {}
for key, item in pairs:
if key in result:
raise SpaceLaunchError(f'Launch assertion {label} contains duplicate key {key!r}')
result[key] = item
return result
try:
decoded = json.loads(value, object_pairs_hook=reject_duplicate_keys)
except SpaceLaunchError:
raise
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SpaceLaunchError(f'Launch assertion {label} is not valid JSON') from exc
if not isinstance(decoded, dict):
raise SpaceLaunchError(f'Launch assertion {label} must be a JSON object')
return decoded
def _required_string(claims: dict[str, typing.Any], name: str) -> str:
value = claims.get(name)
if not isinstance(value, str) or not value or value != value.strip():
raise SpaceLaunchError(f'Launch assertion claim {name} must be a non-empty string')
return value
def _required_int(claims: dict[str, typing.Any], name: str, *, minimum: int = 0) -> int:
value = claims.get(name)
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
raise SpaceLaunchError(f'Launch assertion claim {name} must be an integer >= {minimum}')
return value
def _load_ed25519_public_key(encoded: str) -> Ed25519PublicKey:
value = encoded.strip()
if value.startswith('-----BEGIN'):
try:
key = serialization.load_pem_public_key(value.encode('ascii'))
except (ValueError, TypeError) as exc:
raise SpaceLaunchError('Space launch public key is not valid PEM') from exc
if not isinstance(key, Ed25519PublicKey):
raise SpaceLaunchError('Space launch public key must be Ed25519')
return key
try:
raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True)
except (binascii.Error, ValueError) as exc:
raise SpaceLaunchError('Space launch public key must be base64 encoded') from exc
if len(raw) != 32:
raise SpaceLaunchError('Space launch Ed25519 public key must contain 32 bytes')
return Ed25519PublicKey.from_public_bytes(raw)
class SpaceLaunchService:
"""Verify and single-use consume Space Cloud direct-launch assertions."""
def __init__(
self,
ap: Application,
*,
wall_time: Callable[[], float] = time.time,
) -> None:
self.ap = ap
self._wall_time = wall_time
self._replay_lock = asyncio.Lock()
self._consumed_jtis: dict[str, int] = {}
async def consume_assertion(
self,
assertion: str,
*,
expected_workspace_uuid: str | None = None,
) -> dict[str, str]:
claims = self._verify_assertion(assertion)
payload = claims.get('payload')
if not isinstance(payload, dict):
raise SpaceLaunchError('Launch assertion payload must be a JSON object')
account_uuid = _required_string(payload, 'account_uuid')
workspace_uuid = _required_string(payload, 'workspace_uuid')
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
raise SpaceLaunchError('Launch assertion targets another Workspace')
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
return {
'account_uuid': account_uuid,
'workspace_uuid': workspace_uuid,
}
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
raise SpaceLaunchError('Space direct launch requires verified Cloud mode')
public_key, key_id, clock_skew_seconds = self._trust_config()
segments = token.split('.')
if len(segments) != 3:
raise SpaceLaunchError('Launch assertion must be a compact JWS')
encoded_header, encoded_claims, encoded_signature = segments
header = _strict_json_object(_decode_base64url(encoded_header, label='header'), label='header')
if set(header) != {'alg', 'kid', 'typ'}:
raise SpaceLaunchError('Launch assertion header contains unsupported fields')
if header.get('alg') != 'EdDSA':
raise SpaceLaunchError('Launch assertion algorithm must be EdDSA')
if header.get('kid') != key_id:
raise SpaceLaunchError('Launch assertion key ID does not match Cloud trust')
if header.get('typ') != CONTROL_PLANE_TYP:
raise SpaceLaunchError('Launch assertion type is not a control-plane payload')
signature = _decode_base64url(encoded_signature, label='signature')
if len(signature) != 64:
raise SpaceLaunchError('Launch assertion signature must contain 64 bytes')
try:
public_key.verify(signature, f'{encoded_header}.{encoded_claims}'.encode('ascii'))
except InvalidSignature as exc:
raise SpaceLaunchError('Launch assertion signature is invalid') from exc
claims = _strict_json_object(_decode_base64url(encoded_claims, label='claims'), label='claims')
instance_uuid = self.ap.workspace_service.instance_uuid
if _required_string(claims, 'iss') != EXPECTED_ISSUER:
raise SpaceLaunchError('Launch assertion issuer is not LangBot Space')
if _required_string(claims, 'aud') != EXPECTED_AUDIENCE:
raise SpaceLaunchError('Launch assertion audience does not target Cloud runtime')
if _required_string(claims, 'sub') != f'langbot-instance:{instance_uuid}':
raise SpaceLaunchError('Launch assertion subject targets another instance')
if _required_string(claims, 'instance_uuid') != instance_uuid:
raise SpaceLaunchError('Launch assertion instance UUID does not match this Core')
if _required_string(claims, 'kind') != LAUNCH_KIND:
raise SpaceLaunchError('Launch assertion kind is not workspace.launch')
issued_at = _required_int(claims, 'iat')
not_before = _required_int(claims, 'nbf')
expires_at = _required_int(claims, 'exp', minimum=1)
now = self._wall_time()
if issued_at > now + clock_skew_seconds:
raise SpaceLaunchError('Launch assertion was issued in the future')
if not_before > now + clock_skew_seconds:
raise SpaceLaunchError('Launch assertion is not active yet')
if expires_at <= now - clock_skew_seconds:
raise SpaceLaunchError('Launch assertion is expired')
if expires_at <= max(issued_at, not_before):
raise SpaceLaunchError('Launch assertion expiry must follow issue time')
return claims
def _trust_config(self) -> tuple[Ed25519PublicKey, str, float]:
data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
space_config = data.get('space', {})
launch_config = space_config.get('launch', {}) if isinstance(space_config, dict) else {}
if not isinstance(launch_config, dict):
launch_config = {}
public_key_value = (
os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY', '').strip()
or str(launch_config.get('control_plane_public_key', '') or '').strip()
)
key_id = (
os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_KEY_ID', '').strip()
or str(launch_config.get('control_plane_key_id', '') or '').strip()
or str(getattr(getattr(self.ap, 'deployment', None), 'verification_key_id', '') or '').strip()
)
if not public_key_value or not key_id:
raise SpaceLaunchError('Space launch control-plane trust is not configured')
clock_skew = self._bounded_float(
os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_CLOCK_SKEW_SECONDS')
or launch_config.get('clock_skew_seconds'),
default=30.0,
minimum=0.0,
maximum=300.0,
)
return _load_ed25519_public_key(public_key_value), key_id, clock_skew
async def _consume_jti(self, jti: str, expires_at: int) -> None:
digest = hashlib.sha256(jti.encode('utf-8')).hexdigest()
now = int(self._wall_time())
async with self._replay_lock:
self._consumed_jtis = {
existing: expiry for existing, expiry in self._consumed_jtis.items() if expiry > now
}
if digest in self._consumed_jtis:
raise SpaceLaunchError('Launch assertion has already been consumed')
if len(self._consumed_jtis) >= 4096:
oldest = min(self._consumed_jtis, key=lambda key: self._consumed_jtis[key])
self._consumed_jtis.pop(oldest, None)
self._consumed_jtis[digest] = expires_at
@staticmethod
def _bounded_float(
value: typing.Any,
*,
default: float,
minimum: float,
maximum: float,
) -> float:
try:
result = float(value)
except (TypeError, ValueError):
return default
if not minimum <= result <= maximum:
return default
return result
+6
View File
@@ -47,7 +47,9 @@ from ..survey import manager as survey_module
from ..skill import manager as skill_mgr
from ..workspace import service as workspace_service_module
from ..workspace import collaboration as workspace_collaboration_module
from ..workspace import invitation_delivery as invitation_delivery_module
from ..cloud import bootstrap as cloud_bootstrap_module
from ..cloud import launch as cloud_launch_module
from ..cloud import directory_projection as cloud_directory_projection_module
from ..cloud import entitlements as cloud_entitlements_module
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
@@ -129,6 +131,10 @@ class Application:
workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None
invitation_delivery_service: invitation_delivery_module.InvitationDeliveryService = None
space_launch_service: cloud_launch_module.SpaceLaunchService = None
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
+4
View File
@@ -39,7 +39,9 @@ from ...telemetry import telemetry as telemetry_module
from ...survey import manager as survey_module
from ...workspace import service as workspace_service_module
from ...workspace import collaboration as workspace_collaboration_module
from ...workspace import invitation_delivery as invitation_delivery_module
from ...cloud import bootstrap as cloud_bootstrap
from ...cloud import launch as cloud_launch_module
from ...cloud.directory_projection import DirectoryProjectionService
from ...cloud.entitlements import EntitlementResolver
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
@@ -169,6 +171,8 @@ class BuildAppStage(stage.BootingStage):
ap,
workspace_service_inst,
)
ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap)
ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap)
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
@@ -0,0 +1,75 @@
"""allow Core-owned collaboration writes on Cloud Workspaces
Revision ID: 0015_cloud_core_collab
Revises: 0014_cloud_directory
Create Date: 2026-07-26
Cloud-projected Workspace identity remains projected by the directory
boundary, but membership role/remove and invitation acceptance are now owned
by Core tenant scope.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0015_cloud_core_collab'
down_revision = '0014_cloud_directory'
branch_labels = None
depends_on = None
_TABLE_NAME = 'workspace_memberships'
_POLICY_NAME = 'langbot_workspace_local_directory_write'
_TENANT_SETTING = 'langbot.workspace_uuid'
def _setting(name: str) -> str:
return f"NULLIF(current_setting('{name}', true), '')"
def _quote(conn: sa.Connection, identifier: str) -> str:
return conn.dialect.identifier_preparer.quote(identifier)
def _drop_policy(conn: sa.Connection) -> None:
table = _quote(conn, _TABLE_NAME)
policy = _quote(conn, _POLICY_NAME)
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
def _create_policy(conn: sa.Connection, expression: str) -> None:
table = _quote(conn, _TABLE_NAME)
policy = _quote(conn, _POLICY_NAME)
op.execute(
sa.text(
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
f'USING ({expression}) WITH CHECK ({expression})'
)
)
def upgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql':
return
expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}'
_drop_policy(conn)
_create_policy(conn, expression)
def downgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql':
return
expression = (
f'workspace_uuid::text = {_setting(_TENANT_SETTING)} AND EXISTS ('
'SELECT 1 FROM workspaces AS local_workspace '
'WHERE local_workspace.uuid = workspace_memberships.workspace_uuid '
"AND local_workspace.source = 'local'"
')'
)
_drop_policy(conn)
_create_policy(conn, expression)
+1 -4
View File
@@ -1500,10 +1500,7 @@ class PersistenceManager:
f"(((uuid)::text = {setting(TENANT_SETTING)}) AND ((source)::text = 'local'::text))"
)
local_membership_expression = (
f'(((workspace_uuid)::text = {setting(TENANT_SETTING)}) AND (EXISTS ( SELECT 1\n'
' FROM workspaces local_workspace\n'
' WHERE (((local_workspace.uuid)::text = (workspace_memberships.workspace_uuid)::text) '
"AND ((local_workspace.source)::text = 'local'::text)))))"
f'((workspace_uuid)::text = {setting(TENANT_SETTING)})'
)
local_execution_expression = (
f'(((workspace_uuid)::text = {setting(TENANT_SETTING)}) AND (EXISTS ( SELECT 1\n'
+7 -12
View File
@@ -21,7 +21,6 @@ from ..entity.persistence.workspace import (
Workspace,
WorkspaceInvitation,
WorkspaceMembership,
WorkspaceSource,
WorkspaceStatus,
)
from .entities import WorkspaceExecutionBinding
@@ -319,7 +318,7 @@ class WorkspaceCollaborationService:
session: AsyncSession | None = None,
) -> list[WorkspaceInvitation]:
async def operation(active_session: AsyncSession) -> list[WorkspaceInvitation]:
await self._require_local_workspace(active_session, workspace_uuid)
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)
@@ -357,7 +356,7 @@ class WorkspaceCollaborationService:
raise InvitationError('Invitation expiry must be in the future')
async def operation(active_session: AsyncSession) -> CreatedInvitation:
await self._require_local_workspace(active_session, workspace_uuid)
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(
@@ -430,8 +429,6 @@ class WorkspaceCollaborationService:
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')
if workspace.source != WorkspaceSource.LOCAL.value:
raise InvitationError('Cloud invitations are managed by the SaaS control plane')
return invitation, workspace
return await self._run(operation, session=session)
@@ -458,7 +455,7 @@ class WorkspaceCollaborationService:
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_local_workspace(active_session, invitation.workspace_uuid)
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')
@@ -531,7 +528,7 @@ class WorkspaceCollaborationService:
session: AsyncSession | None = None,
) -> WorkspaceInvitation:
async def operation(active_session: AsyncSession) -> WorkspaceInvitation:
await self._require_local_workspace(active_session, workspace_uuid)
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(
@@ -568,7 +565,7 @@ class WorkspaceCollaborationService:
raise MembershipPermissionError('Unknown Workspace role')
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
await self._require_local_workspace(active_session, workspace_uuid)
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(
@@ -594,7 +591,7 @@ class WorkspaceCollaborationService:
session: AsyncSession | None = None,
) -> WorkspaceMembership:
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
await self._require_local_workspace(active_session, workspace_uuid)
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(
@@ -630,7 +627,7 @@ class WorkspaceCollaborationService:
raise InvitationError('Invitation not found')
return invitation
async def _require_local_workspace(
async def _require_active_workspace(
self,
session: AsyncSession,
workspace_uuid: str,
@@ -642,8 +639,6 @@ class WorkspaceCollaborationService:
or workspace.status != WorkspaceStatus.ACTIVE.value
):
raise WorkspaceNotFoundError('Workspace not found')
if workspace.source != WorkspaceSource.LOCAL.value:
raise MembershipPermissionError('Cloud Workspace directory changes are managed by the SaaS control plane')
return workspace
def _validate_invitation_state(self, invitation: WorkspaceInvitation) -> None:
@@ -0,0 +1,288 @@
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
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) 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 (
f'You were invited to join {workspace_name} on LangBot.\n\n'
f'Open this secure invitation link to continue:\n{invitation_link}\n'
)
@staticmethod
def _html(workspace_name: str, invitation_link: str) -> str:
escaped_workspace = (
workspace_name.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
)
escaped_link = (
invitation_link.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
)
return (
'<p>You were invited to join '
f'<strong>{escaped_workspace}</strong> on LangBot.</p>'
f'<p><a href="{escaped_link}">Accept the invitation</a></p>'
f'<p>{escaped_link}</p>'
)
@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__}')