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 ...authz import Permission
from ...context import RequestContext from ...context import RequestContext
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
from .....workspace.invitation_delivery import InvitationDeliveryService
@group.group_class('system', '/api/v1/system') @group.group_class('system', '/api/v1/system')
@@ -75,6 +76,10 @@ class SystemRouterGroup(group.RouterGroup):
else: else:
outbound_ips = [] 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( return self.success(
data={ data={
'version': constants.semantic_version, 'version': constants.semantic_version,
@@ -97,6 +102,7 @@ class SystemRouterGroup(group.RouterGroup):
'mcp_stdio_enabled': stdio_mcp_enabled(self.ap), 'mcp_stdio_enabled': stdio_mcp_enabled(self.ap),
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
'outbound_ips': outbound_ips, 'outbound_ips': outbound_ips,
'invitation_delivery': invitation_delivery_service.capability(),
'wizard_status': wizard_status, 'wizard_status': wizard_status,
'wizard_progress': wizard_progress, 'wizard_progress': wizard_progress,
} }
@@ -1,11 +1,13 @@
import quart import quart
import argon2 import argon2
import asyncio import asyncio
import uuid
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, urlsplit
from .. import group from .. import group
from .....entity.errors import account as account_errors from .....entity.errors import account as account_errors
from ...context import RequestContext from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
@@ -153,7 +155,20 @@ class UserRouterGroup(group.RouterGroup):
try: try:
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False) 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) authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url}) return self.success(data={'authorize_url': authorize_url})
except ValueError as e: except ValueError as e:
@@ -184,6 +199,14 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json json_data = await quart.request.json
code = json_data.get('code') code = json_data.get('code')
state = json_data.get('state') 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: if not code:
return self.fail(1, 'Missing authorization code') return self.fail(1, 'Missing authorization code')
@@ -191,7 +214,7 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(1, 'Missing state parameter') return self.fail(1, 'Missing state parameter')
try: 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 # Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code) token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
@@ -206,6 +229,24 @@ class UserRouterGroup(group.RouterGroup):
access_token, refresh_token, expires_in 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( return self.success(
data={ data={
'token': jwt_token, 'token': jwt_token,
@@ -331,3 +372,36 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Space account binding failed') return self.http_status(400, -1, 'Space account binding failed')
except Exception: except Exception:
raise 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 .....entity.persistence.workspace import WorkspaceSource
from .....workspace.collaboration import WorkspaceMemberView from .....workspace.collaboration import WorkspaceMemberView
from .....workspace.errors import WorkspaceNotFoundError from .....workspace.errors import WorkspaceNotFoundError
from .....workspace.invitation_delivery import InvitationDeliveryService
from .. import group from .. import group
@@ -153,8 +154,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
) )
async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any: async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context) 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': if quart.request.method == 'GET':
invitations = await self.ap.workspace_collaboration_service.list_invitations( invitations = await self.ap.workspace_collaboration_service.list_invitations(
workspace_uuid, workspace_uuid,
@@ -169,10 +168,20 @@ class WorkspacesRouterGroup(group.RouterGroup):
str(data.get('email', '')), str(data.get('email', '')),
str(data.get('role', 'viewer')), 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( return self.success(
data={ data={
'invitation': _invitation_payload(created.invitation), 'invitation': _invitation_payload(created.invitation),
'token': created.token, 'token': created.token,
'link': link,
'delivery': delivery.to_public_dict(),
} }
) )
@@ -187,8 +196,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
request_context: RequestContext, request_context: RequestContext,
) -> typing.Any: ) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context) 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( invitation = await self.ap.workspace_collaboration_service.revoke_invitation(
workspace_uuid, workspace_uuid,
invitation_uuid, invitation_uuid,
@@ -207,8 +214,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
request_context: RequestContext, request_context: RequestContext,
) -> typing.Any: ) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context) 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 quart.request.method == 'DELETE':
if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions: if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions:
return self.http_status(403, 'permission_denied', 'Member removal permission is required') 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: if workspace_uuid != request_context.workspace_uuid:
raise WorkspaceNotFoundError('Workspace not found') raise WorkspaceNotFoundError('Workspace not found')
async def _requires_control_plane(self, workspace_uuid: str) -> bool: def _invitation_delivery_service(self) -> InvitationDeliveryService:
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid) service = getattr(self.ap, 'invitation_delivery_service', None)
return workspace.source == WorkspaceSource.CLOUD_PROJECTION.value if service is None:
service = InvitationDeliveryService(self.ap)
def _control_plane_required(self) -> typing.Any: self.ap.invitation_delivery_service = service
return self.http_status( return service
409,
'control_plane_required',
'Cloud Workspace membership and invitations are managed by the SaaS control plane',
)
@staticmethod @staticmethod
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]: def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
+31 -8
View File
@@ -6,6 +6,7 @@ import jwt
import datetime import datetime
import typing import typing
import asyncio import asyncio
import dataclasses
import hashlib import hashlib
import secrets import secrets
import time import time
@@ -39,6 +40,13 @@ class AccountDisabledError(ValueError):
code = 'account_disabled' 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: class UserService:
ap: Application ap: Application
_create_user_lock: asyncio.Lock _create_user_lock: asyncio.Lock
@@ -48,7 +56,7 @@ class UserService:
self._create_user_lock = asyncio.Lock() self._create_user_lock = asyncio.Lock()
self._password_hash_lock = asyncio.Semaphore(1) self._password_hash_lock = asyncio.Semaphore(1)
self._space_oauth_state_lock = asyncio.Lock() 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 @staticmethod
def _space_oauth_state_digest(state: str) -> str: def _space_oauth_state_digest(state: str) -> str:
@@ -59,6 +67,7 @@ class UserService:
purpose: typing.Literal['login', 'bind'], purpose: typing.Literal['login', 'bind'],
*, *,
account_uuid: str | None = None, account_uuid: str | None = None,
launch_workspace_uuid: str | None = None,
ttl_seconds: int = 600, ttl_seconds: int = 600,
) -> str: ) -> str:
"""Issue an opaque, single-use OAuth state without exposing a JWT.""" """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') raise ValueError('An Account is required for Space binding')
if purpose == 'login' and account_uuid is not None: if purpose == 'login' and account_uuid is not None:
raise ValueError('Login state cannot be bound to an Account') 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: if ttl_seconds <= 0:
raise ValueError('OAuth state lifetime must be positive') raise ValueError('OAuth state lifetime must be positive')
@@ -78,15 +89,15 @@ class UserService:
if len(self._space_oauth_states) >= 4096: if len(self._space_oauth_states) >= 4096:
oldest = min(self._space_oauth_states, key=lambda key: self._space_oauth_states[key][2]) 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.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 return raw_state
async def consume_space_oauth_state( async def consume_space_oauth_state_details(
self, self,
raw_state: str, raw_state: str,
purpose: typing.Literal['login', 'bind'], purpose: typing.Literal['login', 'bind'],
) -> user.User | None: ) -> SpaceOAuthStateConsumption:
"""Atomically consume OAuth state and resolve its active bind Account.""" """Atomically consume OAuth state and return any bound launch intent."""
if not isinstance(raw_state, str) or not raw_state: if not isinstance(raw_state, str) or not raw_state:
raise ValueError('Invalid or expired OAuth state') raise ValueError('Invalid or expired OAuth state')
digest = self._space_oauth_state_digest(raw_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(): if entry is None or entry[0] != purpose or entry[2] <= time.monotonic():
raise ValueError('Invalid or expired OAuth state') raise ValueError('Invalid or expired OAuth state')
if purpose == 'login': if purpose == 'login':
return None return SpaceOAuthStateConsumption(
purpose='login',
account=None,
launch_workspace_uuid=entry[3],
)
account_uuid = entry[1] account_uuid = entry[1]
account = await self.get_user_by_uuid(account_uuid or '') account = await self.get_user_by_uuid(account_uuid or '')
if account is None: if account is None:
raise ValueError('Invalid or expired OAuth state') raise ValueError('Invalid or expired OAuth state')
self._require_active_account(account) 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 def _hash_password(self, password: str) -> str:
async with self._password_hash_lock: async with self._password_hash_lock:
@@ -209,7 +233,6 @@ class UserService:
) -> tuple[user.User, typing.Any, str]: ) -> tuple[user.User, typing.Any, str]:
"""Create an invited Account and accept its Membership in one transaction.""" """Create an invited Account and accept its Membership in one transaction."""
self._require_local_directory()
normalized_email = normalize_email(user_email) normalized_email = normalize_email(user_email)
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token) invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
if invitation.normalized_email != normalized_email: 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 ..skill import manager as skill_mgr
from ..workspace import service as workspace_service_module from ..workspace import service as workspace_service_module
from ..workspace import collaboration as workspace_collaboration_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 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 directory_projection as cloud_directory_projection_module
from ..cloud import entitlements as cloud_entitlements_module from ..cloud import entitlements as cloud_entitlements_module
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
@@ -129,6 +131,10 @@ class Application:
workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None 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: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = 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 ...survey import manager as survey_module
from ...workspace import service as workspace_service_module from ...workspace import service as workspace_service_module
from ...workspace import collaboration as workspace_collaboration_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 bootstrap as cloud_bootstrap
from ...cloud import launch as cloud_launch_module
from ...cloud.directory_projection import DirectoryProjectionService from ...cloud.directory_projection import DirectoryProjectionService
from ...cloud.entitlements import EntitlementResolver from ...cloud.entitlements import EntitlementResolver
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
@@ -169,6 +171,8 @@ class BuildAppStage(stage.BootingStage):
ap, ap,
workspace_service_inst, 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) user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst 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))" f"(((uuid)::text = {setting(TENANT_SETTING)}) AND ((source)::text = 'local'::text))"
) )
local_membership_expression = ( local_membership_expression = (
f'(((workspace_uuid)::text = {setting(TENANT_SETTING)}) AND (EXISTS ( SELECT 1\n' f'((workspace_uuid)::text = {setting(TENANT_SETTING)})'
' FROM workspaces local_workspace\n'
' WHERE (((local_workspace.uuid)::text = (workspace_memberships.workspace_uuid)::text) '
"AND ((local_workspace.source)::text = 'local'::text)))))"
) )
local_execution_expression = ( local_execution_expression = (
f'(((workspace_uuid)::text = {setting(TENANT_SETTING)}) AND (EXISTS ( SELECT 1\n' 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, Workspace,
WorkspaceInvitation, WorkspaceInvitation,
WorkspaceMembership, WorkspaceMembership,
WorkspaceSource,
WorkspaceStatus, WorkspaceStatus,
) )
from .entities import WorkspaceExecutionBinding from .entities import WorkspaceExecutionBinding
@@ -319,7 +318,7 @@ class WorkspaceCollaborationService:
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> list[WorkspaceInvitation]: ) -> list[WorkspaceInvitation]:
async def operation(active_session: AsyncSession) -> 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) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor)
self._require_member_manager(persisted_actor, workspace_uuid) self._require_member_manager(persisted_actor, workspace_uuid)
await self._expire_pending_invitations(active_session, workspace_uuid=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') raise InvitationError('Invitation expiry must be in the future')
async def operation(active_session: AsyncSession) -> CreatedInvitation: 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) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
self._require_member_manager(persisted_actor, workspace_uuid) self._require_member_manager(persisted_actor, workspace_uuid)
existing_account = await active_session.scalar( existing_account = await active_session.scalar(
@@ -430,8 +429,6 @@ class WorkspaceCollaborationService:
workspace = await active_session.get(Workspace, invitation.workspace_uuid) workspace = await active_session.get(Workspace, invitation.workspace_uuid)
if workspace is None or workspace.status != WorkspaceStatus.ACTIVE.value: if workspace is None or workspace.status != WorkspaceStatus.ACTIVE.value:
raise InvitationError('The invitation Workspace is unavailable') 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 invitation, workspace
return await self._run(operation, session=session) return await self._run(operation, session=session)
@@ -458,7 +455,7 @@ class WorkspaceCollaborationService:
async def operation(active_session: AsyncSession) -> WorkspaceMembership: async def operation(active_session: AsyncSession) -> WorkspaceMembership:
invitation = await self._get_invitation_by_token(active_session, token, for_update=True) invitation = await self._get_invitation_by_token(active_session, token, for_update=True)
self._validate_invitation_state(invitation) 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)) account = await active_session.scalar(sqlalchemy.select(User).where(User.uuid == account_uuid))
if account is None or account.status != AccountStatus.ACTIVE.value: if account is None or account.status != AccountStatus.ACTIVE.value:
raise MembershipNotFoundError('Account not found') raise MembershipNotFoundError('Account not found')
@@ -531,7 +528,7 @@ class WorkspaceCollaborationService:
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> WorkspaceInvitation: ) -> WorkspaceInvitation:
async def operation(active_session: AsyncSession) -> 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) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
self._require_member_manager(persisted_actor, workspace_uuid) self._require_member_manager(persisted_actor, workspace_uuid)
invitation = await active_session.scalar( invitation = await active_session.scalar(
@@ -568,7 +565,7 @@ class WorkspaceCollaborationService:
raise MembershipPermissionError('Unknown Workspace role') raise MembershipPermissionError('Unknown Workspace role')
async def operation(active_session: AsyncSession) -> 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) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
self._require_member_manager(persisted_actor, workspace_uuid) self._require_member_manager(persisted_actor, workspace_uuid)
target = await self._get_active_member_for_update( target = await self._get_active_member_for_update(
@@ -594,7 +591,7 @@ class WorkspaceCollaborationService:
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> WorkspaceMembership: ) -> WorkspaceMembership:
async def operation(active_session: AsyncSession) -> 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) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
self._require_member_manager(persisted_actor, workspace_uuid) self._require_member_manager(persisted_actor, workspace_uuid)
target = await self._get_active_member_for_update( target = await self._get_active_member_for_update(
@@ -630,7 +627,7 @@ class WorkspaceCollaborationService:
raise InvitationError('Invitation not found') raise InvitationError('Invitation not found')
return invitation return invitation
async def _require_local_workspace( async def _require_active_workspace(
self, self,
session: AsyncSession, session: AsyncSession,
workspace_uuid: str, workspace_uuid: str,
@@ -642,8 +639,6 @@ class WorkspaceCollaborationService:
or workspace.status != WorkspaceStatus.ACTIVE.value or workspace.status != WorkspaceStatus.ACTIVE.value
): ):
raise WorkspaceNotFoundError('Workspace not found') 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 return workspace
def _validate_invitation_state(self, invitation: WorkspaceInvitation) -> None: 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__}')
+24
View File
@@ -14,6 +14,30 @@ api:
# login session and without a database record. Leave empty to disable. # login session and without a database record. Leave empty to disable.
# Keep this value secret; only enable it on trusted/internal deployments. # Keep this value secret; only enable it on trusted/internal deployments.
global_api_key: '' global_api_key: ''
workspace:
invitations:
# Public WebUI origin used to build invitation links. Leave empty to
# use api.webui_url, then api.webhook_prefix. Set via
# WORKSPACE__INVITATIONS__PUBLIC_WEB_URL in container deployments.
public_web_url: ''
email:
# Optional invitation email delivery. Empty provider keeps
# invitations link-only. Supported: resend, smtp.
provider: ''
from: ''
timeout_seconds: 10
resend:
api_url: 'https://api.resend.com/emails'
# Secret. Set via WORKSPACE__INVITATIONS__EMAIL__RESEND__API_KEY.
api_key: ''
smtp:
host: ''
port: 587
username: ''
# Secret. Set via WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD.
password: ''
starttls: true
ssl: false
command: command:
enable: true enable: true
prefix: prefix:
+81 -1
View File
@@ -46,10 +46,17 @@ async def space_oauth_api():
local_account if (state, purpose) == ('opaque-bind-state', 'bind') else None local_account if (state, purpose) == ('opaque-bind-state', 'bind') else None
) )
) )
application.user_service.consume_space_oauth_state_details = AsyncMock(
return_value=SimpleNamespace(launch_workspace_uuid=None)
)
application.user_service.bind_space_account = AsyncMock(return_value=bound_account) application.user_service.bind_space_account = AsyncMock(return_value=bound_account)
application.user_service.generate_jwt_token = AsyncMock(return_value='rotated-account-token') application.user_service.generate_jwt_token = AsyncMock(return_value='rotated-account-token')
application.user_service.get_user_by_uuid = AsyncMock(return_value=bound_account)
application.user_service.authenticate_space_user = AsyncMock(return_value=('space-login-token', bound_account)) application.user_service.authenticate_space_user = AsyncMock(return_value=('space-login-token', bound_account))
application.user_service.verify_jwt_token = AsyncMock() application.user_service.verify_jwt_token = AsyncMock()
application.space_launch_service.consume_assertion = AsyncMock(
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access) application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.space_service.get_oauth_authorize_url = Mock( application.space_service.get_oauth_authorize_url = Mock(
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
@@ -87,6 +94,29 @@ async def test_public_login_state_is_server_issued(space_oauth_api):
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login') application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
@pytest.mark.asyncio
async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oauth_api):
application, client = space_oauth_api
application.deployment.multi_workspace_enabled = True
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={
'redirect_uri': 'http://localhost/auth/space/callback',
'launch_workspace_uuid': WORKSPACE_UUID,
},
headers={'Origin': 'http://localhost'},
)
assert response.status_code == 200
authorize_url = (await response.get_json())['data']['authorize_url']
assert parse_qs(urlsplit(authorize_url).query)['state'] == ['opaque-login-state']
application.user_service.issue_space_oauth_state.assert_awaited_once_with(
'login',
launch_workspace_uuid=WORKSPACE_UUID,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_public_login_rejects_caller_supplied_state(space_oauth_api): async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
@@ -203,10 +233,33 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
assert (await missing.get_json())['code'] == 1 assert (await missing.get_json())['code'] == 1
assert response.status_code == 200 assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'space-login-token' assert (await response.get_json())['data']['token'] == 'space-login-token'
application.user_service.consume_space_oauth_state.assert_awaited_once_with('opaque-login-state', 'login') application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code') application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
@pytest.mark.asyncio
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state_details.reset_mock()
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
launch_workspace_uuid=WORKSPACE_UUID
)
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
data = (await response.get_json())['data']
assert data['token'] == 'space-login-token'
assert data['workspace_uuid'] == WORKSPACE_UUID
application.workspace_collaboration_service.resolve_account_workspace.assert_awaited_with(
'account-a',
WORKSPACE_UUID,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api): async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
@@ -234,3 +287,30 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
assert (await response.get_json())['data']['token'] == 'rotated-account-token' assert (await response.get_json())['data']['token'] == 'rotated-account-token'
application.user_service.verify_jwt_token.assert_not_awaited() application.user_service.verify_jwt_token.assert_not_awaited()
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code') application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
@pytest.mark.asyncio
async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock()
application.space_service.exchange_oauth_code.reset_mock()
response = await client.post(
'/api/v1/user/space/callback',
json={
'state': 'space-generated-state-is-not-oauth-state',
'workspace_uuid': WORKSPACE_UUID,
'launch_assertion': 'signed-launch-token',
},
)
assert response.status_code == 200
data = (await response.get_json())['data']
assert data['token'] == 'rotated-account-token'
assert data['workspace_uuid'] == WORKSPACE_UUID
application.space_launch_service.consume_assertion.assert_awaited_once_with(
'signed-launch-token',
expected_workspace_uuid=WORKSPACE_UUID,
)
application.user_service.consume_space_oauth_state.assert_not_awaited()
application.space_service.exchange_oauth_code.assert_not_awaited()
+40 -4
View File
@@ -53,7 +53,7 @@ async def workspace_api(tmp_path):
'jwt': {'secret': 'workspace-api-secret', 'expire': 3600}, 'jwt': {'secret': 'workspace-api-secret', 'expire': 3600},
'allow_modify_login_info': True, 'allow_modify_login_info': True,
}, },
'api': {'global_api_key': ''}, 'api': {'global_api_key': '', 'webui_url': 'https://langbot.example'},
} }
) )
application.logger = logging.getLogger('workspace-api-test') application.logger = logging.getLogger('workspace-api-test')
@@ -198,6 +198,8 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
invite_data = (await invite_response.get_json())['data'] invite_data = (await invite_response.get_json())['data']
invitation_token = invite_data['token'] invitation_token = invite_data['token']
assert invitation_token.startswith('lbi_') assert invitation_token.startswith('lbi_')
assert invite_data['link'] == f'https://langbot.example/invitations/accept#token={invitation_token}'
assert invite_data['delivery'] == {'status': 'link_only', 'provider': None}
assert 'token_hash' not in invite_data['invitation'] assert 'token_hash' not in invite_data['invitation']
async with engine.connect() as connection: async with engine.connect() as connection:
@@ -376,7 +378,7 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac
assert (await forbidden.get_json())['code'] == 'permission_denied' assert (await forbidden.get_json())['code'] == 'permission_denied'
async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_control_plane( async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
workspace_api, workspace_api,
): ):
application, client, engine, owner_token = workspace_api application, client, engine, owner_token = workspace_api
@@ -514,8 +516,42 @@ async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_
headers=_auth(owner_token, cloud_workspace_uuid), headers=_auth(owner_token, cloud_workspace_uuid),
json={'email': 'member@example.com', 'role': 'viewer'}, json={'email': 'member@example.com', 'role': 'viewer'},
) )
assert create_invitation.status_code == 409 assert create_invitation.status_code == 200
assert (await create_invitation.get_json())['code'] == 'control_plane_required' created_invitation = (await create_invitation.get_json())['data']
assert created_invitation['invitation']['workspace_uuid'] == cloud_workspace_uuid
assert created_invitation['link'].startswith('https://langbot.example/invitations/accept#token=lbi_')
assert created_invitation['delivery'] == {'status': 'link_only', 'provider': None}
accept_response = await client.post(
'/api/v1/invitations/accept',
json={
'token': created_invitation['token'],
'registration': {'email': 'member@example.com', 'password': 'member-password'},
},
)
assert accept_response.status_code == 200
member_token = (await accept_response.get_json())['data']['token']
member_current = await client.get(
'/api/v1/workspaces/current',
headers=_auth(member_token, cloud_workspace_uuid),
)
assert member_current.status_code == 200
member_account_uuid = (await member_current.get_json())['data']['membership']['account_uuid']
update_member = await client.patch(
f'/api/v1/workspaces/{cloud_workspace_uuid}/members/{member_account_uuid}',
headers=_auth(owner_token, cloud_workspace_uuid),
json={'role': 'developer'},
)
assert update_member.status_code == 200
assert (await update_member.get_json())['data']['member']['role'] == 'developer'
remove_member = await client.delete(
f'/api/v1/workspaces/{cloud_workspace_uuid}/members/{member_account_uuid}',
headers=_auth(owner_token, cloud_workspace_uuid),
)
assert remove_member.status_code == 200
async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api): async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api):
@@ -973,7 +973,7 @@ class TestPostgreSQLTenantRuntime:
.values(write_fenced=True) .values(write_fenced=True)
) )
assert workspace_update.rowcount == 0 assert workspace_update.rowcount == 0
assert membership_update.rowcount == 0 assert membership_update.rowcount == 1
assert execution_update.rowcount == 0 assert execution_update.rowcount == 0
async with cloud_manager.tenant_uow(workspace_local) as tenant: async with cloud_manager.tenant_uow(workspace_local) as tenant:
@@ -64,12 +64,29 @@ class TestSpaceOAuthState:
service = UserService(SimpleNamespace()) service = UserService(SimpleNamespace())
state = await service.issue_space_oauth_state('login') state = await service.issue_space_oauth_state('login')
digest = service._space_oauth_state_digest(state) digest = service._space_oauth_state_digest(state)
purpose, account_uuid, _ = service._space_oauth_states[digest] purpose, account_uuid, _, launch_workspace_uuid = service._space_oauth_states[digest]
service._space_oauth_states[digest] = (purpose, account_uuid, 0) service._space_oauth_states[digest] = (purpose, account_uuid, 0, launch_workspace_uuid)
with pytest.raises(ValueError, match='Invalid or expired OAuth state'): with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
await service.consume_space_oauth_state(state, 'login') await service.consume_space_oauth_state(state, 'login')
async def test_login_state_can_carry_launch_workspace_without_changing_normal_return(self):
service = UserService(SimpleNamespace())
state = await service.issue_space_oauth_state(
'login',
launch_workspace_uuid='workspace-a',
)
assert await service.consume_space_oauth_state(state, 'login') is None
state = await service.issue_space_oauth_state(
'login',
launch_workspace_uuid='workspace-a',
)
consumed = await service.consume_space_oauth_state_details(state, 'login')
assert consumed.account is None
assert consumed.launch_workspace_uuid == 'workspace-a'
def _create_mock_user( def _create_mock_user(
email: str = 'test@example.com', email: str = 'test@example.com',
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import base64
import json
import time
import uuid
from types import SimpleNamespace
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
pytestmark = pytest.mark.asyncio
INSTANCE_UUID = 'instance-test'
ACCOUNT_UUID = '11111111-1111-4111-8111-111111111111'
WORKSPACE_UUID = '22222222-2222-4222-8222-222222222222'
KEY_ID = 'space-key-1'
def _base64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _sign(private_key: Ed25519PrivateKey, claims: dict, *, key_id: str = KEY_ID) -> str:
header = {'alg': 'EdDSA', 'kid': key_id, 'typ': 'langbot-control-plane+jwt'}
encoded_header = _base64url(json.dumps(header, separators=(',', ':')).encode('utf-8'))
encoded_claims = _base64url(json.dumps(claims, separators=(',', ':')).encode('utf-8'))
signing_input = f'{encoded_header}.{encoded_claims}'
return f'{signing_input}.{_base64url(private_key.sign(signing_input.encode("ascii")))}'
def _claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE_UUID) -> dict:
return {
'iss': 'langbot-space',
'aud': 'langbot-cloud-runtime',
'sub': f'langbot-instance:{INSTANCE_UUID}',
'jti': jti or str(uuid.uuid4()),
'iat': now,
'nbf': now - 5,
'exp': now + 90,
'instance_uuid': INSTANCE_UUID,
'kind': 'workspace.launch',
'payload': {
'account_uuid': ACCOUNT_UUID,
'workspace_uuid': workspace_uuid,
},
}
def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
app = SimpleNamespace(
deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
instance_config=SimpleNamespace(
data={
'space': {
'launch': {
'control_plane_public_key': _base64url(public_key),
}
}
}
),
)
return SpaceLaunchService(app, wall_time=lambda: now)
async def test_consumes_valid_workspace_launch_assertion_once():
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
service = _service(private_key, now=now)
token = _sign(private_key, _claims(now=now))
launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
assert launch == {'account_uuid': ACCOUNT_UUID, 'workspace_uuid': WORKSPACE_UUID}
with pytest.raises(SpaceLaunchError, match='already been consumed'):
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
async def test_rejects_expired_wrong_workspace_and_wrong_instance_assertions():
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
service = _service(private_key, now=now)
expired = _claims(now=now)
expired['exp'] = now - 60
with pytest.raises(SpaceLaunchError, match='expired'):
await service.consume_assertion(_sign(private_key, expired), expected_workspace_uuid=WORKSPACE_UUID)
wrong_workspace = _sign(private_key, _claims(now=now, workspace_uuid='33333333-3333-4333-8333-333333333333'))
with pytest.raises(SpaceLaunchError, match='another Workspace'):
await service.consume_assertion(wrong_workspace, expected_workspace_uuid=WORKSPACE_UUID)
wrong_instance = _claims(now=now)
wrong_instance['instance_uuid'] = 'other-instance'
with pytest.raises(SpaceLaunchError, match='instance UUID'):
await service.consume_assertion(_sign(private_key, wrong_instance), expected_workspace_uuid=WORKSPACE_UUID)
async def test_rejects_invalid_signature_and_non_cloud_mode():
private_key = Ed25519PrivateKey.generate()
now = int(time.time())
token = _sign(private_key, _claims(now=now))
service = _service(Ed25519PrivateKey.generate(), now=now)
with pytest.raises(SpaceLaunchError, match='signature'):
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
oss_service = _service(private_key, now=now)
oss_service.ap.deployment.multi_workspace_enabled = False
with pytest.raises(SpaceLaunchError, match='verified Cloud mode'):
await oss_service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
@@ -0,0 +1,88 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.workspace.invitation_delivery import (
InvitationDeliveryResult,
InvitationDeliveryService,
)
pytestmark = pytest.mark.asyncio
def _app(config: dict) -> SimpleNamespace:
return SimpleNamespace(
instance_config=SimpleNamespace(data=config),
logger=SimpleNamespace(warning=lambda *args, **kwargs: None),
)
async def test_link_only_delivery_builds_public_web_link_without_secret_capability():
service = InvitationDeliveryService(
_app(
{
'api': {
'webui_url': 'https://public.langbot.example/',
'webhook_prefix': 'http://internal:5300',
}
}
)
)
assert service.build_invitation_link('lbi_secret') == (
'https://public.langbot.example/invitations/accept#token=lbi_secret'
)
assert service.capability() == {'enabled': False, 'provider': None}
result = await service.deliver_invitation(
recipient_email='member@example.com',
workspace_name='Workspace',
invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret',
)
assert result == InvitationDeliveryResult(status='link_only', provider=None)
async def test_configured_provider_failure_returns_failed_without_raising():
service = InvitationDeliveryService(
_app(
{
'workspace': {
'invitations': {
'email': {
'provider': 'resend',
'from': 'LangBot <noreply@example.com>',
'resend': {'api_key': 'resend-secret'},
}
}
}
}
)
)
service._send_resend = AsyncMock(return_value=False)
assert service.capability() == {'enabled': True, 'provider': 'resend'}
result = await service.deliver_invitation(
recipient_email='member@example.com',
workspace_name='Workspace',
invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret',
)
assert result == InvitationDeliveryResult(status='failed', provider='resend')
service._send_resend.assert_awaited_once()
async def test_environment_mapping_enables_provider_without_leaking_secret(monkeypatch):
monkeypatch.setenv('WORKSPACE__INVITATIONS__PUBLIC_WEB_URL', 'https://env.langbot.example/')
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__PROVIDER', 'smtp')
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__FROM', 'LangBot <noreply@example.com>')
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__HOST', 'smtp.example.com')
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD', 'smtp-secret')
service = InvitationDeliveryService(_app({'api': {'webui_url': 'https://config.example'}}))
assert service.build_invitation_link('lbi_secret') == (
'https://env.langbot.example/invitations/accept#token=lbi_secret'
)
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
@@ -240,7 +240,7 @@ async def test_cloud_member_listing_opens_workspace_uow_when_request_scope_has_c
] ]
async def test_cloud_directory_requires_explicit_selector_and_rejects_local_mutation( async def test_cloud_directory_requires_explicit_selector_and_allows_core_membership_management(
collaboration_context, collaboration_context,
): ):
_service, workspace_service, session_factory, owner, _workspace, _membership = collaboration_context _service, workspace_service, session_factory, owner, _workspace, _membership = collaboration_context
@@ -292,13 +292,34 @@ async def test_cloud_directory_requires_explicit_selector_and_rejects_local_muta
assert access.workspace.uuid == cloud_workspace_uuid assert access.workspace.uuid == cloud_workspace_uuid
assert access.execution.placement_generation == 8 assert access.execution.placement_generation == 8
with pytest.raises(MembershipPermissionError, match='control plane'): created = await cloud_service.create_invitation(
await cloud_service.create_invitation( cloud_workspace_uuid,
cloud_workspace_uuid, cloud_membership,
cloud_membership, 'member@example.com',
'member@example.com', 'viewer',
'viewer', )
) assert created.invitation.workspace_uuid == cloud_workspace_uuid
assert created.token.startswith('lbi_')
member = await _add_account(session_factory, 'member@example.com')
membership = await cloud_service.accept_invitation(created.token, member.uuid)
assert membership.workspace_uuid == cloud_workspace_uuid
assert membership.role == 'viewer'
updated = await cloud_service.update_member_role(
cloud_workspace_uuid,
member.uuid,
'developer',
cloud_membership,
)
assert updated.role == 'developer'
removed = await cloud_service.remove_member(
cloud_workspace_uuid,
member.uuid,
cloud_membership,
)
assert removed.status == 'removed'
async def test_invitation_lock_registry_does_not_retain_sequential_tokens(collaboration_context): async def test_invitation_lock_registry_does_not_retain_sequential_tokens(collaboration_context):
+41 -12
View File
@@ -27,6 +27,7 @@ import langbotIcon from '@/app/assets/langbot-logo.webp';
type SpaceOAuthLoginResult = { type SpaceOAuthLoginResult = {
token: string; token: string;
user: string; user: string;
workspace_uuid?: string;
}; };
const pendingSpaceOAuthLogins = new Map< const pendingSpaceOAuthLogins = new Map<
@@ -37,15 +38,17 @@ const pendingSpaceOAuthLogins = new Map<
function getOrCreateSpaceOAuthLoginPromise( function getOrCreateSpaceOAuthLoginPromise(
authCode: string, authCode: string,
state: string, state: string,
workspaceUuid?: string,
launchAssertion?: string,
): Promise<SpaceOAuthLoginResult> { ): Promise<SpaceOAuthLoginResult> {
const requestKey = `${authCode}:${state}`; const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey); const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
if (pendingRequest) { if (pendingRequest) {
return pendingRequest; return pendingRequest;
} }
const requestPromise = httpClient const requestPromise = httpClient
.exchangeSpaceOAuthCode(authCode, state) .exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
.finally(() => { .finally(() => {
pendingSpaceOAuthLogins.delete(requestKey); pendingSpaceOAuthLogins.delete(requestKey);
}); });
@@ -70,21 +73,34 @@ function SpaceOAuthCallbackContent() {
const [localEmail, setLocalEmail] = useState<string>(''); const [localEmail, setLocalEmail] = useState<string>('');
const handleOAuthCallback = useCallback( const handleOAuthCallback = useCallback(
async (authCode: string, state: string) => { async (
authCode: string,
state: string,
workspaceUuid?: string,
launchAssertion?: string,
) => {
try { try {
const response = await getOrCreateSpaceOAuthLoginPromise( const response = await getOrCreateSpaceOAuthLoginPromise(
authCode, authCode,
state, state,
workspaceUuid,
launchAssertion,
); );
if (!isMountedRef.current) { if (!isMountedRef.current) {
return; return;
} }
beginAuthenticatedSession(response.token, response.user); beginAuthenticatedSession(response.token, response.user);
const workspaceResult = await bootstrapWorkspaceSession(); const workspaceResult = await bootstrapWorkspaceSession({
preferredWorkspaceUuid: response.workspace_uuid,
});
if (workspaceResult.status === 'unavailable') { if (workspaceResult.status === 'unavailable') {
throw new Error('No Workspace is available for this Account'); throw new Error('No Workspace is available for this Account');
} }
if (response.workspace_uuid) {
navigate('/home', { replace: true });
return;
}
setStatus('success'); setStatus('success');
toast.success(t('common.spaceLoginSuccess')); toast.success(t('common.spaceLoginSuccess'));
@@ -171,6 +187,8 @@ function SpaceOAuthCallbackContent() {
const errorDescription = searchParams.get('error_description'); const errorDescription = searchParams.get('error_description');
const mode = searchParams.get('mode'); const mode = searchParams.get('mode');
const state = searchParams.get('state'); const state = searchParams.get('state');
const workspaceUuid = searchParams.get('workspace_uuid');
const launchAssertion = searchParams.get('launch_assertion');
if (error) { if (error) {
setStatus('error'); setStatus('error');
@@ -180,15 +198,13 @@ function SpaceOAuthCallbackContent() {
return; return;
} }
if (!authCode) {
setStatus('error');
setErrorMessage(t('common.spaceLoginNoCode'));
return;
}
setCode(authCode);
if (mode === 'bind') { if (mode === 'bind') {
if (!authCode) {
setStatus('error');
setErrorMessage(t('common.spaceLoginNoCode'));
return;
}
setCode(authCode);
// Bind mode - verify state (token) exists // Bind mode - verify state (token) exists
if (!state) { if (!state) {
setStatus('error'); setStatus('error');
@@ -199,7 +215,20 @@ function SpaceOAuthCallbackContent() {
setIsBindMode(true); setIsBindMode(true);
setLocalEmail(localStorage.getItem('userEmail') || ''); setLocalEmail(localStorage.getItem('userEmail') || '');
setStatus('confirm'); setStatus('confirm');
} else if (workspaceUuid || launchAssertion) {
if (!workspaceUuid || !launchAssertion) {
setStatus('error');
setErrorMessage(t('common.spaceLoginFailed'));
return;
}
handleOAuthCallback(authCode ?? '', state ?? '', workspaceUuid, launchAssertion);
} else { } else {
if (!authCode) {
setStatus('error');
setErrorMessage(t('common.spaceLoginNoCode'));
return;
}
setCode(authCode);
if (!state) { if (!state) {
setStatus('error'); setStatus('error');
setErrorMessage(t('common.spaceLoginFailed')); setErrorMessage(t('common.spaceLoginFailed'));
@@ -76,20 +76,10 @@ export default function WorkspaceSettingsPanel({
const isCloudProjection = const isCloudProjection =
workspaceInfo?.workspace.source === 'cloud_projection'; workspaceInfo?.workspace.source === 'cloud_projection';
const canViewMembers = permissions.has('member.view'); const canViewMembers = permissions.has('member.view');
const canInvite = !isCloudProjection && permissions.has('member.invite'); const canInvite = permissions.has('member.invite');
const canUpdateMembers = const canUpdateMembers = permissions.has('member.update_role');
!isCloudProjection && permissions.has('member.update_role'); const canRemoveMembers = permissions.has('member.remove');
const canRemoveMembers = const canTransferOwner = permissions.has('owner.transfer');
!isCloudProjection && permissions.has('member.remove');
const canTransferOwner =
!isCloudProjection && permissions.has('owner.transfer');
const canManageCloudMembers =
isCloudProjection &&
(workspaceInfo?.membership.role === 'owner' ||
workspaceInfo?.membership.role === 'admin');
const cloudMembersURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}#workspace-members`
: '';
const cloudPortalURL = workspaceInfo const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan` ? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: ''; : '';
@@ -104,7 +94,6 @@ export default function WorkspaceSettingsPanel({
current.permissions.includes('member.view') current.permissions.includes('member.view')
? backendClient.getWorkspaceMembers(current.workspace.uuid) ? backendClient.getWorkspaceMembers(current.workspace.uuid)
: Promise.resolve({ members: [] }), : Promise.resolve({ members: [] }),
current.workspace.source === 'local' &&
current.permissions.includes('member.invite') current.permissions.includes('member.invite')
? backendClient.getWorkspaceInvitations(current.workspace.uuid) ? backendClient.getWorkspaceInvitations(current.workspace.uuid)
: Promise.resolve({ invitations: [] }), : Promise.resolve({ invitations: [] }),
@@ -131,11 +120,10 @@ export default function WorkspaceSettingsPanel({
inviteEmail.trim(), inviteEmail.trim(),
inviteRole, inviteRole,
); );
const link = `${window.location.origin}/invitations/accept#token=${encodeURIComponent(response.token)}`; setOneTimeInviteLink(response.link);
setOneTimeInviteLink(link);
setInviteEmail(''); setInviteEmail('');
await loadWorkspace(); await loadWorkspace();
toast.success(t('workspace.invitationCreated')); toast.success(t(`workspace.delivery.${response.delivery.status}`));
} catch { } catch {
toast.error(t('workspace.invitationCreateFailed')); toast.error(t('workspace.invitationCreateFailed'));
} finally { } finally {
@@ -310,19 +298,6 @@ export default function WorkspaceSettingsPanel({
<h3 className="text-sm font-semibold"> <h3 className="text-sm font-semibold">
{t('workspace.members')} {t('workspace.members')}
</h3> </h3>
{canManageCloudMembers && (
<Button asChild size="sm" variant="outline">
<a
href={cloudMembersURL}
target="_blank"
rel="noopener noreferrer"
>
<UserPlus className="size-4" />
{t('workspace.inviteMember')}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{members.map((member) => { {members.map((member) => {
+4
View File
@@ -352,6 +352,10 @@ export interface ApiRespSystemInfo {
enable_marketplace: boolean; enable_marketplace: boolean;
allow_modify_login_info: boolean; allow_modify_login_info: boolean;
disable_models_service: boolean; disable_models_service: boolean;
invitation_delivery?: {
enabled: boolean;
provider: 'resend' | 'smtp' | null;
};
limitation: SystemLimitation; limitation: SystemLimitation;
/** Public outbound IPs of the deployment (``system.outbound_ips`` in /** Public outbound IPs of the deployment (``system.outbound_ips`` in
* config.yaml). Shown on adapter config forms whose platform requires * config.yaml). Shown on adapter config forms whose platform requires
+7
View File
@@ -51,3 +51,10 @@ export interface WorkspaceInvitation {
expires_at: string; expires_at: string;
created_at: string; created_at: string;
} }
export type WorkspaceInvitationDeliveryStatus = 'sent' | 'link_only' | 'failed';
export interface WorkspaceInvitationDelivery {
status: WorkspaceInvitationDeliveryStatus;
provider: 'resend' | 'smtp' | null;
}
+16 -2
View File
@@ -65,6 +65,7 @@ import type {
CurrentWorkspace, CurrentWorkspace,
Workspace, Workspace,
WorkspaceInvitation, WorkspaceInvitation,
WorkspaceInvitationDelivery,
WorkspaceMembership, WorkspaceMembership,
WorkspaceBootstrapResponse, WorkspaceBootstrapResponse,
WorkspaceRole, WorkspaceRole,
@@ -1192,7 +1193,12 @@ export class BackendClient extends BaseHttpClient {
workspaceUuid: string, workspaceUuid: string,
email: string, email: string,
role: Exclude<WorkspaceRole, 'owner'>, role: Exclude<WorkspaceRole, 'owner'>,
): Promise<{ invitation: WorkspaceInvitation; token: string }> { ): Promise<{
invitation: WorkspaceInvitation;
token: string;
link: string;
delivery: WorkspaceInvitationDelivery;
}> {
return this.post(`/api/v1/workspaces/${workspaceUuid}/invitations`, { return this.post(`/api/v1/workspaces/${workspaceUuid}/invitations`, {
email, email,
role, role,
@@ -1318,13 +1324,21 @@ export class BackendClient extends BaseHttpClient {
public async exchangeSpaceOAuthCode( public async exchangeSpaceOAuthCode(
code: string, code: string,
state: string, state: string,
workspaceUuid?: string,
launchAssertion?: string,
): Promise<{ ): Promise<{
token: string; token: string;
user: string; user: string;
workspace_uuid?: string;
}> { }> {
const response = await this.instance.post( const response = await this.instance.post(
'/api/v1/user/space/callback', '/api/v1/user/space/callback',
{ code, state }, {
code,
state,
workspace_uuid: workspaceUuid,
launch_assertion: launchAssertion,
},
{ skipWorkspace: true } as RequestConfig, { skipWorkspace: true } as RequestConfig,
); );
if (response.data.code !== 0) { if (response.data.code !== 0) {
+4
View File
@@ -30,6 +30,10 @@ export const systemInfo: ApiRespSystemInfo = {
cloud_service_url: '', cloud_service_url: '',
allow_modify_login_info: true, allow_modify_login_info: true,
disable_models_service: false, disable_models_service: false,
invitation_delivery: {
enabled: false,
provider: null,
},
limitation: { limitation: {
max_bots: -1, max_bots: -1,
max_pipelines: -1, max_pipelines: -1,
+8 -4
View File
@@ -1300,7 +1300,7 @@ const enUS = {
ossSingletonDescription: ossSingletonDescription:
'This self-hosted instance has one Workspace and can include multiple users.', 'This self-hosted instance has one Workspace and can include multiple users.',
cloudManagedDescription: cloudManagedDescription:
'Membership, invitations, and billing for this Workspace are managed in the LangBot Cloud portal.', 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
loadFailed: 'Failed to load Workspace information', loadFailed: 'Failed to load Workspace information',
members: 'Members', members: 'Members',
you: 'You', you: 'You',
@@ -1310,9 +1310,13 @@ const enUS = {
emailPlaceholder: 'member@example.com', emailPlaceholder: 'member@example.com',
createInvitation: 'Create invitation', createInvitation: 'Create invitation',
invitationCreated: 'Invitation created', invitationCreated: 'Invitation created',
delivery: {
sent: 'Invitation sent',
link_only: 'Invitation link created',
failed: 'Invitation link created, but email could not be sent',
},
invitationCreateFailed: 'Failed to create invitation', invitationCreateFailed: 'Failed to create invitation',
oneTimeLinkWarning: oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
'Copy this link now. The secret is shown only once and is not stored by LangBot.',
copyInvitation: 'Copy invitation link', copyInvitation: 'Copy invitation link',
invitationCopied: 'Invitation link copied', invitationCopied: 'Invitation link copied',
pendingInvitations: 'Pending invitations', pendingInvitations: 'Pending invitations',
@@ -1323,7 +1327,7 @@ const enUS = {
acceptInvitation: 'Accept invitation', acceptInvitation: 'Accept invitation',
invitedToWorkspace: 'You were invited to {{workspace}}', invitedToWorkspace: 'You were invited to {{workspace}}',
checkingInvitation: 'Checking this invitation...', checkingInvitation: 'Checking this invitation...',
invitationMissing: 'The invitation secret is missing from this link.', invitationMissing: 'This invitation link is missing required information.',
invitationExpired: 'This invitation has expired.', invitationExpired: 'This invitation has expired.',
invitationAlreadyRevoked: 'This invitation was revoked.', invitationAlreadyRevoked: 'This invitation was revoked.',
invitationAlreadyUsed: 'This invitation was already used.', invitationAlreadyUsed: 'This invitation was already used.',
+8 -3
View File
@@ -1301,7 +1301,7 @@ const jaJP = {
ossSingletonDescription: ossSingletonDescription:
'このセルフホストインスタンスには1つのワークスペースがあり、複数のユーザーを追加できます。', 'このセルフホストインスタンスには1つのワークスペースがあり、複数のユーザーを追加できます。',
cloudManagedDescription: cloudManagedDescription:
'このワークスペースのメンバー、招待、請求は LangBot Cloud ポータルで管理されます。', 'このワークスペースは LangBot Cloud でホストされています。メンバーはここで管理し、請求は Cloud で開きます。',
loadFailed: 'ワークスペース情報の読み込みに失敗しました', loadFailed: 'ワークスペース情報の読み込みに失敗しました',
members: 'メンバー', members: 'メンバー',
you: 'あなた', you: 'あなた',
@@ -1311,9 +1311,14 @@ const jaJP = {
emailPlaceholder: 'member@example.com', emailPlaceholder: 'member@example.com',
createInvitation: '招待を作成', createInvitation: '招待を作成',
invitationCreated: '招待を作成しました', invitationCreated: '招待を作成しました',
delivery: {
sent: '招待メールを送信しました',
link_only: '招待リンクを作成しました',
failed: '招待リンクを作成しましたが、メールを送信できませんでした',
},
invitationCreateFailed: '招待の作成に失敗しました', invitationCreateFailed: '招待の作成に失敗しました',
oneTimeLinkWarning: oneTimeLinkWarning:
'このリンクを今すぐコピーしてください。シークレットは一度だけ表示され、LangBotには保存されません。', 'このリンクを今すぐコピーしてください。一度だけ表示されます。',
copyInvitation: '招待リンクをコピー', copyInvitation: '招待リンクをコピー',
invitationCopied: '招待リンクをコピーしました', invitationCopied: '招待リンクをコピーしました',
pendingInvitations: '保留中の招待', pendingInvitations: '保留中の招待',
@@ -1324,7 +1329,7 @@ const jaJP = {
acceptInvitation: '招待を承認', acceptInvitation: '招待を承認',
invitedToWorkspace: '{{workspace}} に招待されました', invitedToWorkspace: '{{workspace}} に招待されました',
checkingInvitation: '招待を確認しています...', checkingInvitation: '招待を確認しています...',
invitationMissing: '招待リンクにシークレットがありません。', invitationMissing: 'この招待リンクには必要な情報がありません。',
invitationExpired: 'この招待は期限切れです。', invitationExpired: 'この招待は期限切れです。',
invitationAlreadyRevoked: 'この招待は取り消されました。', invitationAlreadyRevoked: 'この招待は取り消されました。',
invitationAlreadyUsed: 'この招待はすでに使用されています。', invitationAlreadyUsed: 'この招待はすでに使用されています。',
+8 -4
View File
@@ -1234,7 +1234,7 @@ const zhHans = {
ossSingletonDescription: ossSingletonDescription:
'当前自托管实例只有一个工作区,但可以包含多个用户。', '当前自托管实例只有一个工作区,但可以包含多个用户。',
cloudManagedDescription: cloudManagedDescription:
'此工作区的成员、邀请与计费由 LangBot Cloud 控制台统一管理。', '此工作区托管于 LangBot Cloud。成员在此管理,计费在 Cloud 中打开。',
loadFailed: '加载工作区信息失败', loadFailed: '加载工作区信息失败',
members: '成员', members: '成员',
you: '你', you: '你',
@@ -1243,9 +1243,13 @@ const zhHans = {
emailPlaceholder: 'member@example.com', emailPlaceholder: 'member@example.com',
createInvitation: '创建邀请', createInvitation: '创建邀请',
invitationCreated: '邀请已创建', invitationCreated: '邀请已创建',
delivery: {
sent: '邀请邮件已发送',
link_only: '邀请链接已创建',
failed: '邀请链接已创建,但邮件发送失败',
},
invitationCreateFailed: '创建邀请失败', invitationCreateFailed: '创建邀请失败',
oneTimeLinkWarning: oneTimeLinkWarning: '请立即复制此链接。它只显示一次。',
'请立即复制此链接。密钥只显示一次,LangBot 不会保存明文。',
copyInvitation: '复制邀请链接', copyInvitation: '复制邀请链接',
invitationCopied: '邀请链接已复制', invitationCopied: '邀请链接已复制',
pendingInvitations: '待接受邀请', pendingInvitations: '待接受邀请',
@@ -1256,7 +1260,7 @@ const zhHans = {
acceptInvitation: '接受邀请', acceptInvitation: '接受邀请',
invitedToWorkspace: '你已受邀加入 {{workspace}}', invitedToWorkspace: '你已受邀加入 {{workspace}}',
checkingInvitation: '正在验证邀请…', checkingInvitation: '正在验证邀请…',
invitationMissing: '邀请链接缺少密钥。', invitationMissing: '邀请链接缺少必要信息。',
invitationExpired: '此邀请已过期。', invitationExpired: '此邀请已过期。',
invitationAlreadyRevoked: '此邀请已被撤销。', invitationAlreadyRevoked: '此邀请已被撤销。',
invitationAlreadyUsed: '此邀请已被使用。', invitationAlreadyUsed: '此邀请已被使用。',
@@ -62,10 +62,11 @@ test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it
); );
}); });
test('links Cloud workspace managers to the Space member invitation surface', () => { test('keeps Cloud workspace member management in Workspace Settings', () => {
assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/);
assert.match(workspaceSettingsPanelSource, /#workspace-members/);
assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/); assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/);
assert.doesNotMatch(workspaceSettingsPanelSource, /#workspace-members/);
assert.doesNotMatch(workspaceSettingsPanelSource, /cloudMembersURL/);
assert.doesNotMatch(workspaceSettingsPanelSource, /canManageCloudMembers/);
}); });
test('keeps workspace plan and settings controls on the workspace row', () => { test('keeps workspace plan and settings controls on the workspace row', () => {
@@ -85,13 +86,7 @@ test('moves Cloud plan upgrades into Workspace Settings', () => {
assert.match(workspaceSettingsPanelSource, /workspace\.upgradePlan/); assert.match(workspaceSettingsPanelSource, /workspace\.upgradePlan/);
assert.match(workspaceSettingsPanelSource, /cloudPortalURL/); assert.match(workspaceSettingsPanelSource, /cloudPortalURL/);
assert.match(workspaceSettingsPanelSource, /step=plan/); assert.match(workspaceSettingsPanelSource, /step=plan/);
const toolbarEnd = workspaceSettingsPanelSource.indexOf('</PanelToolbar>'); assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/);
const membersHeading =
workspaceSettingsPanelSource.indexOf('workspace.members');
const cloudInvite = workspaceSettingsPanelSource.indexOf(
'href={cloudMembersURL}',
);
assert.ok(toolbarEnd < membersHeading && membersHeading < cloudInvite);
}); });
test('aligns the workspace trigger with navigation entries on both sides and truncates long names', () => { test('aligns the workspace trigger with navigation entries on both sides and truncates long names', () => {