mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 14:26:06 +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 ...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]:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 ..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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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('&', '&').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.
|
# 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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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: 'この招待はすでに使用されています。',
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user