mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat(workspace): add in-product collaboration and direct Cloud launch
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
|
||||
@@ -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('&', '&').replace('<', '<').replace('>', '>')
|
||||
)
|
||||
escaped_link = (
|
||||
invitation_link.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
)
|
||||
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__}')
|
||||
@@ -14,6 +14,30 @@ api:
|
||||
# login session and without a database record. Leave empty to disable.
|
||||
# Keep this value secret; only enable it on trusted/internal deployments.
|
||||
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:
|
||||
enable: true
|
||||
prefix:
|
||||
|
||||
Reference in New Issue
Block a user