diff --git a/src/langbot/pkg/api/http/authz.py b/src/langbot/pkg/api/http/authz.py index 36b796fe7..225e30aa8 100644 --- a/src/langbot/pkg/api/http/authz.py +++ b/src/langbot/pkg/api/http/authz.py @@ -74,6 +74,11 @@ class AuthorizationError(Exception): error_code = 'forbidden' +class AuthenticationDeniedError(AuthorizationError): + status_code = 401 + error_code = 'invalid_authentication' + + class WorkspaceRequiredError(AuthorizationError): status_code = 400 error_code = 'workspace_required' diff --git a/src/langbot/pkg/api/http/context.py b/src/langbot/pkg/api/http/context.py index 7880a1e15..f9c7b4258 100644 --- a/src/langbot/pkg/api/http/context.py +++ b/src/langbot/pkg/api/http/context.py @@ -9,6 +9,7 @@ class PrincipalType(enum.StrEnum): ACCOUNT = 'account' API_KEY = 'api_key' + SUPPORT_ADMIN = 'support_admin' SYSTEM = 'system' PUBLIC_BOT = 'public_bot' @@ -19,7 +20,9 @@ class PrincipalContext: principal_type: PrincipalType account_uuid: str | None = None + actor_account_uuid: str | None = None api_key_uuid: str | None = None + support_session_id: str | None = None @dataclasses.dataclass(frozen=True, slots=True) diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 1fc09a0b2..0917c6c6d 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -15,8 +15,17 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl from ....workspace.errors import WorkspaceNotFoundError from ....cloud.entitlements import EntitlementUnavailableError from ....core.errors import TaskCapacityError -from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission +from ..authz import ( + AuthenticationDeniedError, + AuthorizationError, + Permission, + PermissionDeniedError, + WorkspaceRequiredError, + permissions_for_role, + require_permission, +) from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext +from ....cloud.support_admin import SupportAdminSessionError if typing.TYPE_CHECKING: from ....core.app import Application @@ -51,6 +60,17 @@ class AuthType(enum.Enum): USER_TOKEN_OR_API_KEY = 'user-token-or-api-key' +_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset( + { + Permission.OWNER_TRANSFER.value, + Permission.MEMBER_VIEW.value, + Permission.MEMBER_INVITE.value, + Permission.MEMBER_UPDATE_ROLE.value, + Permission.MEMBER_REMOVE.value, + } +) + + class RouterGroup(abc.ABC): name: str @@ -95,6 +115,10 @@ class RouterGroup(abc.ABC): return self.http_status(401, -1, 'No valid user token provided') try: + if self._is_support_admin_token(token): + raise AuthenticationDeniedError( + 'Support admin tokens cannot be refreshed or used on account endpoints' + ) account, user_email = await self._authenticate_account(token) # Account-token routes deliberately stop before Workspace # selection. They may bootstrap a selector, but cannot @@ -111,8 +135,13 @@ class RouterGroup(abc.ABC): return self.http_status(401, -1, 'No valid user token provided') try: - account, user_email = await self._authenticate_account(token) - request_context = await self._resolve_account_context(account, auth_type) + request_context = await self._authenticate_support_admin(token, auth_type) + if request_context is not None: + self._require_support_admin_route_allowed(rule, f, permission) + user_email = None + else: + account, user_email = await self._authenticate_account(token) + request_context = await self._resolve_account_context(account, auth_type) if permission is not None: if request_context is None: raise AuthorizationError('Workspace authorization is unavailable') @@ -141,10 +170,20 @@ class RouterGroup(abc.ABC): return self._auth_error_response(e) elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: + token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') + if token and self._is_support_admin_token(token): + try: + request_context = await self._authenticate_support_admin(token, auth_type) + if request_context is None: + raise AuthenticationDeniedError('Invalid support admin token') + self._require_support_admin_route_allowed(rule, f, permission) + if permission is not None: + require_permission(request_context, permission) + self._inject_handler_context(f, kwargs, None, request_context) + except Exception as e: + return self._auth_error_response(e) # Try API key first (check X-API-Key header) - api_key = quart.request.headers.get('X-API-Key', '') - - if api_key: + elif api_key := quart.request.headers.get('X-API-Key', ''): # API key authentication try: request_context = await self._authenticate_api_key(api_key, auth_type) @@ -155,8 +194,6 @@ class RouterGroup(abc.ABC): return self._auth_error_response(e) else: # Try user token authentication (Authorization header) - token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') - if not token: return self.http_status( 401, -1, 'No valid authentication provided (user token or API key required)' @@ -268,10 +305,83 @@ class RouterGroup(abc.ABC): raise ValueError('User not found') return account, account.user + def _is_support_admin_token(self, token: str) -> bool: + service = getattr(self.ap, 'support_admin_session_service', None) + detector = getattr(service, 'is_support_admin_token', None) + return callable(detector) and detector(token) is True + + async def _authenticate_support_admin( + self, + token: str, + auth_type: AuthType, + *, + workspace_uuid: str | None = None, + request_id: str | None = None, + ) -> RequestContext | None: + service = getattr(self.ap, 'support_admin_session_service', None) + detector = getattr(service, 'is_support_admin_token', None) + if service is None or not callable(detector) or detector(token) is not True: + return None + + requested_workspace_uuid = ( + workspace_uuid if workspace_uuid is not None else quart.request.headers.get('X-Workspace-Id') + ) + if not requested_workspace_uuid: + raise WorkspaceRequiredError('Support admin token requires an explicit Workspace selector') + try: + identity = await service.authenticate_token( + token, + requested_workspace_uuid=requested_workspace_uuid, + ) + except SupportAdminSessionError as exc: + raise AuthenticationDeniedError(str(exc)) from exc + + entitlement_revision = await self._resolve_entitlement_revision( + identity.instance_uuid, + identity.workspace_uuid, + ) + request_context = RequestContext( + instance_uuid=identity.instance_uuid, + placement_generation=identity.placement_generation, + request_id=request_id or self.request_id(), + auth_type=auth_type.value, + principal=PrincipalContext( + principal_type=PrincipalType.SUPPORT_ADMIN, + actor_account_uuid=identity.actor_account_uuid, + support_session_id=identity.grant_jti_hash, + ), + workspace=WorkspaceContext( + workspace_uuid=identity.workspace_uuid, + membership_uuid=None, + role='owner', + permissions=permissions_for_role('owner') - _SUPPORT_ADMIN_DENIED_PERMISSIONS, + membership_revision=0, + ), + entitlement_revision=entitlement_revision, + ) + quart.g.request_context = request_context + quart.g.workspace_membership = None + return request_context + + @staticmethod + def _require_support_admin_route_allowed( + rule: str, + handler: RouteCallable, + permission: Permission | str | None, + ) -> None: + parameters = inspect.signature(handler).parameters + if rule.startswith('/api/v1/user/') or 'account' in parameters or 'user_email' in parameters: + raise AuthenticationDeniedError('Support admin tokens are not permitted on account endpoints') + permission_value = permission.value if isinstance(permission, Permission) else permission + if permission_value in _SUPPORT_ADMIN_DENIED_PERMISSIONS: + raise PermissionDeniedError(permission_value) + async def _resolve_account_context( self, account: typing.Any, auth_type: AuthType, + *, + token: str | None = None, ) -> RequestContext | None: collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None) account_uuid = getattr(account, 'uuid', None) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py index 565107117..056217348 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py @@ -97,6 +97,16 @@ class WebSocketChatRouterGroup(group.RouterGroup): if not token or not workspace_uuid: raise ValueError('Authentication is required') + support_context = await self._authenticate_support_admin( + token, + group.AuthType.USER_TOKEN, + workspace_uuid=workspace_uuid, + request_id=quart.websocket.headers.get('X-Request-Id') or str(uuid.uuid4()), + ) + if support_context is not None: + require_permission(support_context, Permission.RUNTIME_OPERATE) + return support_context, token + account, _ = await self._authenticate_account(token) account_uuid = getattr(account, 'uuid', None) collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None) @@ -131,6 +141,23 @@ class WebSocketChatRouterGroup(group.RouterGroup): ) -> RequestContext: """Recheck revocable account, membership, permission, and placement state.""" + if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN: + current_context = await self._authenticate_support_admin( + token, + group.AuthType.USER_TOKEN, + workspace_uuid=request_context.workspace_uuid, + request_id=request_context.request_id, + ) + if current_context is None or current_context.principal != request_context.principal: + raise ValueError('WebSocket support admin session changed') + if ( + current_context.instance_uuid != request_context.instance_uuid + or current_context.placement_generation != request_context.placement_generation + ): + raise ValueError('WebSocket authorization changed') + require_permission(current_context, Permission.RUNTIME_OPERATE) + return current_context + account, _ = await self._authenticate_account(token) account_uuid = getattr(account, 'uuid', None) if account_uuid != request_context.account_uuid: @@ -211,6 +238,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): scope=WebSocketScope.from_context(request_context), pipeline_uuid=pipeline_uuid, session_type=session_type, + trigger_principal=request_context.principal, metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')}, send_queue_size=( self.ap.instance_config.data.get('system', {}) @@ -391,7 +419,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): ) elif message_type == 'message': try: - await self._revalidate_websocket_authorization(request_context, token) + request_context = await self._revalidate_websocket_authorization(request_context, token) except Exception: await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'}) break diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index edfbf9615..75a046e8c 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -22,6 +22,7 @@ class _AdapterSessionScope: principal_type: str account_uuid: str | None api_key_uuid: str | None + support_session_id: str | None @classmethod def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope': @@ -33,6 +34,7 @@ class _AdapterSessionScope: principal_type=principal.principal_type.value, account_uuid=principal.account_uuid, api_key_uuid=principal.api_key_uuid, + support_session_id=principal.support_session_id, ) def matches(self, request_context: RequestContext) -> bool: diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index bbcda6c6b..7ad46d999 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -318,6 +318,13 @@ class PluginsRouterGroup(group.RouterGroup): ) return await operation() + async def _require_authenticated_plugin_runtime_context( + self, + request_context: RequestContext, + ) -> ExecutionContext: + """Fence an authenticated resource request to its injected Workspace.""" + return await self.ap.plugin_connector.require_workspace_context(request_context) + async def _require_public_plugin_runtime_context(self) -> ExecutionContext: """Resolve public assets only for the OSS singleton Workspace. @@ -372,7 +379,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) plugins = await self.ap.plugin_connector.list_plugins() return self.success(data={'plugins': redact_plugin_secrets(plugins)}) @@ -385,7 +392,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """Get plugin debug information including debug URL and key""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) debug_info = await self.ap.plugin_connector.get_debug_info() # Get debug URL from config @@ -428,7 +435,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) if plugin is None: return self.http_status(404, -1, 'plugin not found') @@ -469,7 +476,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) if plugin is None: return self.http_status(404, -1, 'plugin not found') @@ -489,7 +496,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) if plugin is None: return self.http_status(404, -1, 'plugin not found') @@ -506,7 +513,7 @@ class PluginsRouterGroup(group.RouterGroup): ) except ValueError as exc: return self.http_status(400, -1, str(exc)) - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config) return self.success(data={}) @@ -517,7 +524,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) language = quart.request.args.get('language', 'en') readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language) return self.success(data={'readme': readme}) @@ -529,7 +536,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.AUDIT_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) try: limit = int(quart.request.args.get('limit', 200)) except (TypeError, ValueError): @@ -538,6 +545,44 @@ class PluginsRouterGroup(group.RouterGroup): logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level) return self.success(data={'logs': logs}) + @self.route( + '///authenticated-icon', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.RESOURCE_VIEW, + ) + async def _( + author: str, + plugin_name: str, + request_context: RequestContext, + ) -> quart.Response: + await self._require_authenticated_plugin_runtime_context(request_context) + icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) + icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64']) + return quart.Response(icon_bytes, mimetype=icon_data['mime_type']) + + @self.route( + '///authenticated-assets/', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.RESOURCE_VIEW, + ) + async def _( + author: str, + plugin_name: str, + filepath: str, + request_context: RequestContext, + ) -> quart.Response: + await self._require_authenticated_plugin_runtime_context(request_context) + asset_path = _normalize_plugin_asset_path(filepath) + if asset_path is None: + return quart.Response('Asset not found', status=404) + asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path) + if not asset_data.get('asset_base64'): + return quart.Response('Asset not found', status=404) + asset_bytes = await asyncio.to_thread(base64.b64decode, asset_data['asset_base64']) + return quart.Response(asset_bytes, mimetype=asset_data['mime_type']) + @self.route( '///icon', methods=['GET'], @@ -596,7 +641,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: """Forward a page API request to the plugin.""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) data = await quart.request.json if not isinstance(data, dict): return self.http_status(400, -1, 'invalid request body') @@ -625,7 +670,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """Get releases from a GitHub repository URL""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) data = await quart.request.json repo_url = data.get('repo_url', '') @@ -705,7 +750,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """Get assets from a specific GitHub release""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) data = await quart.request.json owner = data.get('owner', '') repo = data.get('repo', '') @@ -901,7 +946,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(request_context: RequestContext) -> str: - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') @@ -942,7 +987,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """Upload a file for plugin configuration""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') @@ -974,7 +1019,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(file_key: str, request_context: RequestContext) -> str: """Delete a plugin configuration file""" - await self.ap.plugin_connector.require_workspace_context(request_context) + await self._require_authenticated_plugin_runtime_context(request_context) if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'): return self.http_status(400, -1, 'invalid file key') diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 8fa38bf73..8b86ba361 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -396,6 +396,19 @@ class UserRouterGroup(group.RouterGroup): launch_assertion, expected_workspace_uuid=workspace_uuid, ) + if launch.get('launch_mode') == 'support_admin': + token = launch.get('support_admin_token') + if not token: + raise SpaceLaunchError('Support admin launch session was not issued') + return self.success( + data={ + 'token': token, + 'workspace_uuid': launch['workspace_uuid'], + 'principal_type': 'support_admin', + 'actor_account_uuid': launch['actor_account_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') diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 5c43cf64b..6b6817b07 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -5,7 +5,7 @@ import typing import quart from ...authz import Permission, permissions_for_role -from ...context import RequestContext +from ...context import PrincipalType, RequestContext from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership from .....entity.persistence.workspace import WorkspaceSource @@ -120,9 +120,6 @@ class WorkspacesRouterGroup(group.RouterGroup): @self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW) async def _(request_context: RequestContext) -> typing.Any: membership = quart.g.workspace_membership - account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid) - if account is None: - return self.http_status(401, 'invalid_authentication', 'Account not found') workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid) plan_name: str | None = None resolver = getattr(self.ap, 'entitlement_resolver', None) @@ -132,6 +129,28 @@ class WorkspacesRouterGroup(group.RouterGroup): minimum_revision=request_context.entitlement_revision, ) plan_name = entitlement.plan_name + if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN: + return self.success( + data={ + 'workspace': _workspace_payload(workspace), + 'membership': { + 'uuid': None, + 'workspace_uuid': request_context.workspace_uuid, + 'account_uuid': None, + 'email': None, + 'role': 'owner', + 'status': 'active', + 'joined_at': None, + 'created_at': None, + }, + 'permissions': sorted(request_context.workspace.permissions), + 'placement_generation': request_context.placement_generation, + 'plan_name': plan_name, + } + ) + account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid) + if account is None: + return self.http_status(401, 'invalid_authentication', 'Account not found') return self.success( data={ 'workspace': _workspace_payload(workspace), diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 93a3441ad..d1ae53608 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -400,7 +400,10 @@ class UserService: return await self.generate_jwt_token(user_obj) - async def generate_jwt_token(self, account: user.User | str) -> str: + async def generate_jwt_token( + self, + account: user.User | str, + ) -> str: jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] jwt_expire = self.ap.instance_config.data['system']['jwt']['expire'] @@ -413,7 +416,7 @@ class UserService: # Lightweight unit-test and bootstrap callers may not have persistence wired. account_obj = None - payload = { + payload: dict[str, typing.Any] = { 'user': user_email, 'iss': self._jwt_identity()[0], 'aud': self._jwt_identity()[1], diff --git a/src/langbot/pkg/cloud/launch.py b/src/langbot/pkg/cloud/launch.py index ac5006870..ddc808beb 100644 --- a/src/langbot/pkg/cloud/launch.py +++ b/src/langbot/pkg/cloud/launch.py @@ -15,12 +15,15 @@ from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from .support_admin import SupportAdminReplayError, SupportAdminSessionError, hash_grant_jti + if typing.TYPE_CHECKING: from ..core.app import Application CONTROL_PLANE_TYP = 'langbot-control-plane+jwt' LAUNCH_KIND = 'workspace.launch' +SUPPORT_ADMIN_LAUNCH_KIND = 'workspace.support_admin_launch' EXPECTED_ISSUER = 'langbot-space' EXPECTED_AUDIENCE = 'langbot-cloud-runtime' _CONSUMED_JTI_MAX_ENTRIES = 4096 @@ -123,15 +126,60 @@ class SpaceLaunchService: 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') + kind = _required_string(claims, 'kind') 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 { + if kind == SUPPORT_ADMIN_LAUNCH_KIND: + if 'account_uuid' in payload: + raise SpaceLaunchError('Admin launch assertion must not identify a customer Account') + if payload.get('launch_mode') != 'support_admin' or payload.get('principal_type') != 'support_admin': + raise SpaceLaunchError('Admin launch principal must be support_admin') + actor_account_uuid = _required_string(payload, 'actor_account_uuid') + if _required_string(payload, 'effective_role') != 'owner': + raise SpaceLaunchError('Admin launch effective role must be owner') + issued_at = _required_int(claims, 'iat') + expires_at = _required_int(claims, 'exp', minimum=1) + if expires_at - issued_at > 90: + raise SpaceLaunchError('Admin launch assertion lifetime exceeds 90 seconds') + grant_jti_hash = hash_grant_jti(_required_string(claims, 'jti')) + result = { + 'workspace_uuid': workspace_uuid, + 'launch_mode': 'support_admin', + 'actor_account_uuid': actor_account_uuid, + 'effective_role': 'owner', + 'grant_jti_hash': grant_jti_hash, + } + support_service = getattr(self.ap, 'support_admin_session_service', None) + if support_service is None or not callable(getattr(support_service, 'consume_launch_grant', None)): + raise SpaceLaunchError('Durable support admin session service is unavailable') + try: + support_session = await support_service.consume_launch_grant( + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + ) + except SupportAdminReplayError as exc: + raise SpaceLaunchError('Launch assertion has already been consumed') from exc + except SupportAdminSessionError as exc: + raise SpaceLaunchError(str(exc)) from exc + result['support_admin_token'] = support_session.token + self.ap.logger.info( + 'cloud_support_admin_launch_consumed actor_account_uuid=%s workspace_uuid=%s', + result['actor_account_uuid'], + workspace_uuid, + ) + return result + + if payload.get('launch_mode') is not None: + raise SpaceLaunchError('Launch assertion mode is unsupported') + account_uuid = _required_string(payload, 'account_uuid') + result = { 'account_uuid': account_uuid, 'workspace_uuid': workspace_uuid, } + await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1)) + return result def _verify_assertion(self, token: str) -> dict[str, typing.Any]: if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): @@ -169,8 +217,9 @@ class SpaceLaunchService: 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') + kind = _required_string(claims, 'kind') + if kind not in {LAUNCH_KIND, SUPPORT_ADMIN_LAUNCH_KIND}: + raise SpaceLaunchError('Launch assertion kind is not supported') issued_at = _required_int(claims, 'iat') not_before = _required_int(claims, 'nbf') diff --git a/src/langbot/pkg/cloud/support_admin.py b/src/langbot/pkg/cloud/support_admin.py new file mode 100644 index 000000000..bd0607a20 --- /dev/null +++ b/src/langbot/pkg/cloud/support_admin.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import dataclasses +import datetime +import hashlib +import re +import time +import typing + +import jwt +from sqlalchemy.exc import IntegrityError + +from ..entity.persistence.support_admin import SupportAdminTemporarySession +from ..workspace.errors import WorkspaceError + +if typing.TYPE_CHECKING: + from ..core.app import Application + + +SUPPORT_ADMIN_TOKEN_TYP = 'langbot-support-admin+jwt' +SUPPORT_ADMIN_TOKEN_KIND = 'support_admin.session' +SUPPORT_ADMIN_EFFECTIVE_ROLE = 'owner' +SUPPORT_ADMIN_MAX_TOKEN_SECONDS = 300 +_SHA256_HEX = re.compile(r'^[0-9a-f]{64}$') + + +class SupportAdminSessionError(ValueError): + """Raised when a support-admin session or token is not admissible.""" + + +class SupportAdminReplayError(SupportAdminSessionError): + """Raised when a launch grant JTI has already been consumed.""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class IssuedSupportAdminSession: + token: str + grant_jti_hash: str + workspace_uuid: str + actor_account_uuid: str + issued_at: datetime.datetime + expires_at: datetime.datetime + + +@dataclasses.dataclass(frozen=True, slots=True) +class SupportAdminSessionIdentity: + grant_jti_hash: str + workspace_uuid: str + actor_account_uuid: str + instance_uuid: str + placement_generation: int + + +def hash_grant_jti(jti: str) -> str: + return hashlib.sha256(jti.encode('utf-8')).hexdigest() + + +class SupportAdminSessionService: + """Issue and validate temporary Workspace-scoped support-admin sessions.""" + + def __init__( + self, + ap: Application, + *, + wall_time: typing.Callable[[], float] = time.time, + ) -> None: + self.ap = ap + self._wall_time = wall_time + + async def consume_launch_grant( + self, + *, + grant_jti_hash: str, + workspace_uuid: str, + actor_account_uuid: str, + ) -> IssuedSupportAdminSession: + self._validate_grant_hash(grant_jti_hash) + if not workspace_uuid or not actor_account_uuid: + raise SupportAdminSessionError('Support admin session requires an actor and Workspace') + + issued_at = self._utcnow() + expires_at = issued_at + datetime.timedelta(seconds=SUPPORT_ADMIN_MAX_TOKEN_SECONDS) + tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if not callable(tenant_uow): + raise SupportAdminSessionError('Support admin sessions require tenant persistence') + + try: + async with tenant_uow(workspace_uuid) as uow: + await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session) + uow.session.add( + SupportAdminTemporarySession( + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + issued_at=issued_at, + expires_at=expires_at, + ) + ) + await uow.session.flush() + except IntegrityError as exc: + raise SupportAdminReplayError('Launch assertion has already been consumed') from exc + except WorkspaceError as exc: + raise SupportAdminSessionError('Workspace is unavailable for support access') from exc + + return IssuedSupportAdminSession( + token=self._encode_token( + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + issued_at=issued_at, + expires_at=expires_at, + ), + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + issued_at=issued_at, + expires_at=expires_at, + ) + + def is_support_admin_token(self, token: str) -> bool: + """Return True only for compact JWTs marked as support-admin tokens.""" + + if not isinstance(token, str) or token.count('.') != 2: + return False + try: + header = jwt.get_unverified_header(token) + except jwt.PyJWTError: + return False + if header.get('typ') == SUPPORT_ADMIN_TOKEN_TYP: + return True + try: + payload = jwt.decode(token, options={'verify_signature': False}) + except jwt.PyJWTError: + return False + return payload.get('kind') == SUPPORT_ADMIN_TOKEN_KIND + + async def authenticate_token( + self, + token: str, + *, + requested_workspace_uuid: str | None, + ) -> SupportAdminSessionIdentity: + if not self.is_support_admin_token(token): + raise SupportAdminSessionError('Not a support admin token') + workspace_uuid = (requested_workspace_uuid or '').strip() + if not workspace_uuid: + raise SupportAdminSessionError('Support admin token requires an explicit Workspace selector') + + jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] + try: + payload = jwt.decode( + token, + jwt_secret, + algorithms=['HS256'], + issuer='langbot-core', + audience=self._audience(workspace_uuid), + options={'require': ['exp', 'iat', 'nbf', 'iss', 'aud']}, + ) + except jwt.PyJWTError as exc: + raise SupportAdminSessionError('Invalid support admin token') from exc + self._validate_payload(payload, workspace_uuid) + grant_jti_hash = payload['grant_jti_hash'] + actor_account_uuid = payload['actor_account_uuid'] + + tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if not callable(tenant_uow): + raise SupportAdminSessionError('Support admin sessions require tenant persistence') + + now = self._utcnow() + async with tenant_uow(workspace_uuid) as uow: + session = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash) + if ( + session is None + or session.workspace_uuid != workspace_uuid + or session.actor_account_uuid != actor_account_uuid + or session.revoked_at is not None + or session.expires_at <= now + ): + raise SupportAdminSessionError('Support admin session is inactive') + binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session) + session.last_used_at = now + await uow.session.flush() + + return SupportAdminSessionIdentity( + grant_jti_hash=grant_jti_hash, + workspace_uuid=workspace_uuid, + actor_account_uuid=actor_account_uuid, + instance_uuid=binding.instance_uuid, + placement_generation=binding.placement_generation, + ) + + async def revoke_session(self, grant_jti_hash: str, workspace_uuid: str) -> None: + self._validate_grant_hash(grant_jti_hash) + now = self._utcnow() + async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow: + row = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash) + if row is not None and row.revoked_at is None: + row.revoked_at = now + + def _encode_token( + self, + *, + grant_jti_hash: str, + workspace_uuid: str, + actor_account_uuid: str, + issued_at: datetime.datetime, + expires_at: datetime.datetime, + ) -> str: + jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] + payload: dict[str, typing.Any] = { + 'kind': SUPPORT_ADMIN_TOKEN_KIND, + 'iss': 'langbot-core', + 'aud': self._audience(workspace_uuid), + 'sub': f'support-admin:{actor_account_uuid}', + 'iat': issued_at, + 'nbf': issued_at, + 'exp': expires_at, + 'actor_account_uuid': actor_account_uuid, + 'workspace_uuid': workspace_uuid, + 'effective_role': SUPPORT_ADMIN_EFFECTIVE_ROLE, + 'grant_jti_hash': grant_jti_hash, + } + return jwt.encode(payload, jwt_secret, algorithm='HS256', headers={'typ': SUPPORT_ADMIN_TOKEN_TYP}) + + def _validate_payload(self, payload: dict[str, typing.Any], workspace_uuid: str) -> None: + if payload.get('kind') != SUPPORT_ADMIN_TOKEN_KIND: + raise SupportAdminSessionError('Invalid support admin token kind') + if payload.get('workspace_uuid') != workspace_uuid: + raise SupportAdminSessionError('Support admin session is scoped to another Workspace') + if payload.get('effective_role') != SUPPORT_ADMIN_EFFECTIVE_ROLE: + raise SupportAdminSessionError('Invalid support admin token role') + actor_account_uuid = payload.get('actor_account_uuid') + if not isinstance(actor_account_uuid, str) or not actor_account_uuid.strip(): + raise SupportAdminSessionError('Invalid support admin actor') + grant_jti_hash = payload.get('grant_jti_hash') + if not isinstance(grant_jti_hash, str) or not _SHA256_HEX.match(grant_jti_hash): + raise SupportAdminSessionError('Invalid support admin grant') + + def _audience(self, workspace_uuid: str) -> str: + return f'langbot-support-admin:{self.ap.workspace_service.instance_uuid}:{workspace_uuid}' + + @staticmethod + def _validate_grant_hash(grant_jti_hash: str) -> None: + if not _SHA256_HEX.match(grant_jti_hash): + raise SupportAdminSessionError('Invalid support admin grant') + + def _utcnow(self) -> datetime.datetime: + return datetime.datetime.fromtimestamp(self._wall_time(), datetime.UTC).replace(tzinfo=None) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index cd4c30611..a7187159a 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -53,6 +53,7 @@ from ..workspace import collaboration as workspace_collaboration_module from ..workspace import invitation_delivery as invitation_delivery_module from ..cloud import bootstrap as cloud_bootstrap_module from ..cloud import launch as cloud_launch_module +from ..cloud import support_admin as cloud_support_admin_module from ..cloud import directory_projection as cloud_directory_projection_module from ..cloud import entitlements as cloud_entitlements_module from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType @@ -141,6 +142,8 @@ class Application: space_launch_service: cloud_launch_module.SpaceLaunchService = None + support_admin_session_service: cloud_support_admin_module.SupportAdminSessionService = None + deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index a5ab36f83..0233d04ca 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -44,6 +44,7 @@ from ...workspace import collaboration as workspace_collaboration_module from ...workspace import invitation_delivery as invitation_delivery_module from ...cloud import bootstrap as cloud_bootstrap from ...cloud import launch as cloud_launch_module +from ...cloud import support_admin as cloud_support_admin_module from ...cloud.directory import directory_projection_limits_from_config from ...cloud.directory_projection import DirectoryProjectionService from ...cloud.entitlements import EntitlementResolver @@ -185,6 +186,7 @@ class BuildAppStage(stage.BootingStage): workspace_service_inst, ) ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap) + ap.support_admin_session_service = cloud_support_admin_module.SupportAdminSessionService(ap) ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap) user_service_inst = user_service.UserService(ap) diff --git a/src/langbot/pkg/entity/persistence/support_admin.py b/src/langbot/pkg/entity/persistence/support_admin.py new file mode 100644 index 000000000..3575d0135 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/support_admin.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import sqlalchemy + +from .base import Base + + +class SupportAdminTemporarySession(Base): + """Temporary support-admin Workspace access session.""" + + __tablename__ = 'support_admin_temporary_sessions' + + grant_jti_hash = sqlalchemy.Column(sqlalchemy.String(64), primary_key=True) + workspace_uuid = sqlalchemy.Column( + sqlalchemy.String(36), + sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'), + nullable=False, + ) + actor_account_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False) + issued_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False) + expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False) + revoked_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + + __table_args__ = ( + sqlalchemy.Index( + 'ix_support_admin_sessions_workspace_expiry', + 'workspace_uuid', + 'expires_at', + ), + sqlalchemy.CheckConstraint( + 'length(grant_jti_hash) = 64', + name='ck_support_admin_sessions_grant_jti_hash', + ), + ) diff --git a/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py b/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py index d0a6c233e..1da7d0286 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py +++ b/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py @@ -18,6 +18,17 @@ down_revision = '0008_mcp_resource_prefs' branch_labels = None depends_on = None +_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76') + + +def _workspace_uuid_from_instance_id(instance_id: str) -> str: + value = instance_id.strip() + candidate = value[len('instance_') :] if value.startswith('instance_') else value + try: + return str(uuid.UUID(candidate)) + except ValueError: + return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value)) + def _table_names(conn: sa.Connection) -> set[str]: return set(sa.inspect(conn).get_table_names()) @@ -403,7 +414,7 @@ def _bootstrap_default_workspace(conn: sa.Connection) -> None: .values(created_by_account_uuid=owner_account_uuid) ) else: - workspace_uuid = str(uuid.uuid4()) + workspace_uuid = _workspace_uuid_from_instance_id(instance_uuid) conn.execute( workspaces.insert().values( uuid=workspace_uuid, diff --git a/src/langbot/pkg/persistence/alembic/versions/0016_support_admin_sessions.py b/src/langbot/pkg/persistence/alembic/versions/0016_support_admin_sessions.py new file mode 100644 index 000000000..28a1b34d6 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0016_support_admin_sessions.py @@ -0,0 +1,88 @@ +"""add temporary support-admin sessions + +Revision ID: 0016_support_admin_sessions +Revises: 0015_cloud_core_collab +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = '0016_support_admin_sessions' +down_revision = '0015_cloud_core_collab' +branch_labels = None +depends_on = None + + +_TABLE_NAME = 'support_admin_temporary_sessions' +_POLICY_NAME = 'langbot_workspace_isolation' +_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 upgrade() -> None: + conn = op.get_bind() + existing_tables = set(sa.inspect(conn).get_table_names()) + if _TABLE_NAME not in existing_tables: + op.create_table( + _TABLE_NAME, + sa.Column('grant_jti_hash', sa.String(64), nullable=False), + sa.Column( + 'workspace_uuid', + sa.String(36), + sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('actor_account_uuid', sa.String(36), nullable=False), + sa.Column('issued_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('revoked_at', sa.DateTime(), nullable=True), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.CheckConstraint( + 'length(grant_jti_hash) = 64', + name='ck_support_admin_sessions_grant_jti_hash', + ), + sa.PrimaryKeyConstraint('grant_jti_hash'), + ) + op.create_index( + 'ix_support_admin_sessions_workspace_expiry', + _TABLE_NAME, + ['workspace_uuid', 'expires_at'], + unique=False, + ) + + if conn.dialect.name != 'postgresql': + return + + table = _quote(conn, _TABLE_NAME) + policy = _quote(conn, _POLICY_NAME) + expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}' + op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.execute( + sa.text( + f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC ' + f'USING ({expression}) WITH CHECK ({expression})' + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + if conn.dialect.name == 'postgresql': + table = _quote(conn, _TABLE_NAME) + policy = _quote(conn, _POLICY_NAME) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + op.drop_index('ix_support_admin_sessions_workspace_expiry', table_name=_TABLE_NAME) + op.drop_table(_TABLE_NAME) diff --git a/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py b/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py new file mode 100644 index 000000000..105f529be --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0017_oss_workspace_identity.py @@ -0,0 +1,167 @@ +"""align the OSS Workspace UUID with the persisted instance identity + +Revision ID: 0017_oss_workspace_identity +Revises: 0016_support_admin_sessions +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +import uuid + +import sqlalchemy as sa +from alembic import op + + +revision = '0017_oss_workspace_identity' +down_revision = '0016_support_admin_sessions' +branch_labels = None +depends_on = None + +_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76') +_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid' + + +def _workspace_uuid_from_instance_id(instance_id: str) -> str: + value = instance_id.strip() + candidate = value[len('instance_') :] if value.startswith('instance_') else value + try: + return str(uuid.UUID(candidate)) + except ValueError: + return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value)) + + +def _quote(conn: sa.Connection, identifier: str) -> str: + return conn.dialect.identifier_preparer.quote(identifier) + + +def _defer_foreign_keys(conn: sa.Connection, inspector: sa.Inspector, table_names: list[str]) -> None: + """Allow the transaction to re-key a connected tenant graph atomically.""" + + if conn.dialect.name == 'sqlite': + conn.execute(sa.text('PRAGMA defer_foreign_keys = ON')) + return + if conn.dialect.name != 'postgresql': + raise RuntimeError(f'Unsupported Workspace identity migration dialect: {conn.dialect.name}') + + for table_name in table_names: + for foreign_key in inspector.get_foreign_keys(table_name): + constraint_name = foreign_key.get('name') + if not constraint_name: + continue + conn.execute( + sa.text( + f'ALTER TABLE {_quote(conn, table_name)} ' + f'ALTER CONSTRAINT {_quote(conn, constraint_name)} DEFERRABLE INITIALLY DEFERRED' + ) + ) + + +def _suspend_postgres_rls( + conn: sa.Connection, + table_names: list[str], +) -> dict[str, tuple[bool, bool]]: + if conn.dialect.name != 'postgresql': + return {} + + states: dict[str, tuple[bool, bool]] = {} + for table_name in table_names: + row = conn.execute( + sa.text('SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = to_regclass(:table_name)'), + {'table_name': table_name}, + ).one() + enabled, forced = bool(row.relrowsecurity), bool(row.relforcerowsecurity) + states[table_name] = (enabled, forced) + table = _quote(conn, table_name) + if forced: + conn.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY')) + if enabled: + conn.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY')) + return states + + +def _restore_postgres_rls(conn: sa.Connection, states: dict[str, tuple[bool, bool]]) -> None: + for table_name, (enabled, forced) in states.items(): + table = _quote(conn, table_name) + if enabled: + conn.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')) + if forced: + conn.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')) + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + table_names = inspector.get_table_names() + if 'workspaces' not in table_names: + return + + metadata = sa.MetaData() + workspaces = sa.Table('workspaces', metadata, autoload_with=conn) + local_rows = conn.execute(sa.select(workspaces).where(workspaces.c.source == 'local')).mappings().all() + if not local_rows: + return + if len(local_rows) != 1: + raise RuntimeError('Cannot align OSS Workspace identity: expected exactly one local Workspace') + + old_row = dict(local_rows[0]) + old_uuid = old_row['uuid'] + canonical_uuid = _workspace_uuid_from_instance_id(old_row['instance_uuid']) + if old_uuid == canonical_uuid: + return + if conn.execute(sa.select(workspaces.c.uuid).where(workspaces.c.uuid == canonical_uuid)).scalar_one_or_none(): + raise RuntimeError(f'Cannot align OSS Workspace identity: target {canonical_uuid!r} already exists') + + tenant_tables = [ + table_name + for table_name in table_names + if table_name == 'workspaces' + or 'workspace_uuid' in {column['name'] for column in inspector.get_columns(table_name)} + ] + rls_states = _suspend_postgres_rls(conn, tenant_tables) + try: + _defer_foreign_keys(conn, inspector, table_names) + + # Release local source/slug uniqueness while the canonical parent exists + # alongside the old parent for the duration of this transaction. + temporary_slug = f'__workspace_rekey__{old_uuid}' + conn.execute( + workspaces.update() + .where(workspaces.c.uuid == old_uuid) + .values(source='cloud_projection', slug=temporary_slug) + ) + new_row = dict(old_row) + new_row['uuid'] = canonical_uuid + conn.execute(workspaces.insert().values(**new_row)) + + for table_name in tenant_tables: + if table_name == 'workspaces': + continue + table = sa.Table(table_name, metadata, autoload_with=conn, extend_existing=True) + conn.execute(table.update().where(table.c.workspace_uuid == old_uuid).values(workspace_uuid=canonical_uuid)) + + if 'metadata' in table_names: + conn.execute( + sa.text('UPDATE metadata SET value = :canonical_uuid WHERE key = :key AND value = :old_uuid'), + { + 'canonical_uuid': canonical_uuid, + 'key': _OSS_WORKSPACE_METADATA_KEY, + 'old_uuid': old_uuid, + }, + ) + conn.execute(workspaces.delete().where(workspaces.c.uuid == old_uuid)) + if conn.dialect.name == 'postgresql': + # Fire deferred FK triggers before ALTER TABLE restores RLS; PostgreSQL + # rejects ALTER TABLE while a relation has pending trigger events. + conn.execute(sa.text('SET CONSTRAINTS ALL IMMEDIATE')) + except Exception: + # Alembic owns the transaction. Rollback restores the transactional RLS DDL. + raise + else: + _restore_postgres_rls(conn, rls_states) + + +def downgrade() -> None: + # The previous random UUID is intentionally not recoverable. Keeping the + # canonical identity preserves every FK and is safe for older application code. + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py b/src/langbot/pkg/persistence/alembic/versions/0018_repair_local_workspace_owner.py similarity index 95% rename from src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py rename to src/langbot/pkg/persistence/alembic/versions/0018_repair_local_workspace_owner.py index c6d5ed36c..59c33a8dc 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py +++ b/src/langbot/pkg/persistence/alembic/versions/0018_repair_local_workspace_owner.py @@ -1,7 +1,7 @@ """repair an ownerless local Workspace after tenancy migration -Revision ID: 0017_local_owner_repair -Revises: 0016_agent_workspace +Revision ID: 0018_local_owner_repair +Revises: 0016_agent_workspace, 0017_oss_workspace_identity Create Date: 2026-07-31 """ @@ -14,8 +14,8 @@ import sqlalchemy as sa from alembic import op -revision = '0017_local_owner_repair' -down_revision = '0016_agent_workspace' +revision = '0018_local_owner_repair' +down_revision = ('0016_agent_workspace', '0017_oss_workspace_identity') branch_labels = None depends_on = None diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 9c16a5eda..9c67656d5 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -53,6 +53,7 @@ _ALEMBIC_TENANT_TABLES = { 'workspace_memberships', 'workspace_invitations', 'workspace_execution_states', + 'support_admin_temporary_sessions', 'workspace_metadata', 'api_keys', 'agents', diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index 2b5bbb5a3..1c5ccdf5d 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -43,6 +43,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = { 'workspace_memberships': 'workspace_uuid', 'workspace_invitations': 'workspace_uuid', 'workspace_execution_states': 'workspace_uuid', + 'support_admin_temporary_sessions': 'workspace_uuid', 'workspace_metadata': 'workspace_uuid', 'api_keys': 'workspace_uuid', 'agents': 'workspace_uuid', diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index fb6d2818b..4ed1c3fb7 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -16,6 +16,7 @@ from ....agent.runner.config_resolver import RunnerConfigResolver from ....agent.runner import config_schema from ....utils import constants, runner as runner_utils from ....telemetry import features as telemetry_features +from ....telemetry.identity import workspace_identity import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message @@ -303,7 +304,8 @@ class ChatMessageHandler(handler.MessageHandler): 'duration_ms': duration_ms, 'model_name': model_name, 'version': constants.semantic_version, - 'instance_id': constants.instance_id, + **workspace_identity(get_query_execution_context(query)), + 'runtime_instance_id': constants.instance_id, 'edition': constants.edition, 'pipeline_plugins': pipeline_plugins, 'features': features, diff --git a/src/langbot/pkg/platform/sources/telegram.py b/src/langbot/pkg/platform/sources/telegram.py index 7ff7109ca..f78b56893 100644 --- a/src/langbot/pkg/platform/sources/telegram.py +++ b/src/langbot/pkg/platform/sources/telegram.py @@ -179,10 +179,13 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte ) file_format = 'image/jpeg' + # NOTE: Telegram's file.file_path is a full URL of the form + # https://api.telegram.org/file/bot/ which embeds the + # bot token. Unlike the public CDN URLs used by other adapters, it + # cannot be exposed safely, so only base64 is stored here. encoded = await asyncio.to_thread(base64.b64encode, file_bytes) message_components.append( platform_message.Image( - url=file.file_path, base64=f'data:{file_format};base64,{encoded.decode("utf-8")}', ) ) diff --git a/src/langbot/pkg/platform/sources/websocket_manager.py b/src/langbot/pkg/platform/sources/websocket_manager.py index 99ef98bba..03f94e517 100644 --- a/src/langbot/pkg/platform/sources/websocket_manager.py +++ b/src/langbot/pkg/platform/sources/websocket_manager.py @@ -9,7 +9,7 @@ from datetime import datetime import pydantic -from ...api.http.context import ExecutionContext +from ...api.http.context import ExecutionContext, PrincipalContext logger = logging.getLogger(__name__) _SESSION_FILTER_UNSET = object() @@ -95,6 +95,9 @@ class WebSocketConnection(pydantic.BaseModel): metadata: dict = pydantic.Field(default_factory=dict) """连接元数据(可存储额外信息)""" + trigger_principal: PrincipalContext | None = None + """Authenticated principal that opened this dashboard connection.""" + @property def scope(self) -> WebSocketScope: return WebSocketScope( @@ -112,6 +115,7 @@ class WebSocketConnection(pydantic.BaseModel): workspace_uuid=self.workspace_uuid, placement_generation=self.placement_generation, pipeline_uuid=self.pipeline_uuid, + trigger_principal=self.trigger_principal, ) @@ -138,6 +142,7 @@ class WebSocketConnectionManager: pipeline_uuid: str, session_type: str, metadata: dict | None = None, + trigger_principal: PrincipalContext | None = None, session_id: str | None = None, send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE, max_connections: int = 1024, @@ -174,6 +179,7 @@ class WebSocketConnectionManager: session_id=session_id, websocket=websocket, metadata=metadata or {}, + trigger_principal=trigger_principal, send_queue=asyncio.Queue(maxsize=send_queue_size), ) diff --git a/src/langbot/pkg/telemetry/heartbeat.py b/src/langbot/pkg/telemetry/heartbeat.py index 3c3efb507..2dd2b2afd 100644 --- a/src/langbot/pkg/telemetry/heartbeat.py +++ b/src/langbot/pkg/telemetry/heartbeat.py @@ -27,6 +27,19 @@ if typing.TYPE_CHECKING: HEARTBEAT_INTERVAL_SECONDS = 24 * 3600 +class WorkspaceResourceSnapshot(typing.TypedDict): + workspace_uuid: str + bot_count: int + pipeline_count: int + knowledge_base_count: int + plugin_count: int + mcp_server_count: int + extension_count: int + skill_count: int + adapters: list[str] + execution_generation: int + + async def _count( ap: core_app.Application, table, @@ -52,8 +65,64 @@ async def _count( return -1 -async def build_heartbeat_payload(ap: core_app.Application) -> dict: - """Collect the anonymous instance profile snapshot.""" +async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -> list[WorkspaceResourceSnapshot]: + """Summarize already-loaded Cloud registries without per-tenant SQL.""" + persistence_mgr = ap.persistence_mgr + if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime': + return [] + + counts: dict[str, WorkspaceResourceSnapshot] = { + binding.workspace_uuid: { + 'workspace_uuid': binding.workspace_uuid, + 'bot_count': 0, + 'pipeline_count': 0, + 'knowledge_base_count': 0, + 'plugin_count': 0, + 'mcp_server_count': 0, + 'extension_count': 0, + 'skill_count': 0, + 'adapters': [], + 'execution_generation': binding.placement_generation, + } + for binding in bindings + } + + adapter_sets: dict[str, set[str]] = {workspace_uuid: set() for workspace_uuid in counts} + for key, bot in getattr(ap.platform_mgr, '_bots_by_key', {}).items(): + if len(key) >= 2 and key[1] in counts: + counts[key[1]]['bot_count'] += 1 + adapter = getattr(bot, 'adapter', None) + if adapter is not None and getattr(bot, 'enable', False): + adapter_sets[key[1]].add(adapter.__class__.__name__) + for key in getattr(ap.pipeline_mgr, '_pipelines_by_key', {}): + if len(key) >= 2 and key[1] in counts: + counts[key[1]]['pipeline_count'] += 1 + for key in getattr(ap.rag_mgr, 'knowledge_bases', {}): + if len(key) >= 1 and key[0] in counts: + counts[key[0]]['knowledge_base_count'] += 1 + for key in getattr(ap.tool_mgr.mcp_tool_loader, '_sessions', {}): + if len(key) >= 2 and key[1] in counts: + counts[key[1]]['mcp_server_count'] += 1 + for workspace_uuid, installations in getattr(ap.plugin_connector, '_workspace_installations', {}).items(): + if workspace_uuid in counts: + counts[workspace_uuid]['plugin_count'] = len(installations) + for key, skills in getattr(ap.skill_mgr, '_skills_by_scope', {}).items(): + if len(key) >= 2 and key[1] in counts: + counts[key[1]]['skill_count'] += len(skills) + + for workspace_uuid, resource in counts.items(): + resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count'] + resource['adapters'] = sorted(adapter_sets[workspace_uuid]) + return list(counts.values()) + + +async def build_heartbeat_payload( + ap: core_app.Application, + *, + workspace_uuid: str, + workspace_resource: WorkspaceResourceSnapshot | None = None, +) -> dict: + """Collect one anonymous Workspace profile snapshot.""" from ..entity.persistence import bot as persistence_bot from ..entity.persistence import mcp as persistence_mcp from ..entity.persistence import pipeline as persistence_pipeline @@ -136,11 +205,14 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict: except Exception: pass + if workspace_resource is not None: + features.update({key: value for key, value in workspace_resource.items() if key != 'workspace_uuid'}) + return { 'event_type': 'instance_heartbeat', 'query_id': '', 'version': constants.semantic_version, - 'instance_id': constants.instance_id, + 'workspace_uuid': workspace_uuid, 'instance_create_ts': constants.instance_create_ts, 'edition': constants.edition, 'features': features, @@ -148,14 +220,34 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict: } +async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]: + """Build one heartbeat per active Workspace.""" + bindings = await ap.workspace_service.list_active_execution_bindings() + workspace_uuids = sorted({binding.workspace_uuid for binding in bindings}) + resources = { + resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings) + } + return [ + await build_heartbeat_payload( + ap, + workspace_uuid=workspace_uuid, + workspace_resource=resources.get(workspace_uuid), + ) + for workspace_uuid in workspace_uuids + ] + + async def heartbeat_loop(ap: core_app.Application) -> None: """Send one heartbeat shortly after startup, then daily.""" # Small delay so managers (platform, skills, plugins) finish loading first await asyncio.sleep(30) while True: try: - payload = await build_heartbeat_payload(ap) - await ap.telemetry.start_send_task(payload) + for payload in await build_heartbeat_payloads(ap): + # Heartbeats are a daily bounded batch, not best-effort query events. + # Await each send so the TelemetryManager's 8-task queue cannot drop + # Workspaces after the first batch. + await ap.telemetry.send(payload) except Exception as e: try: ap.logger.debug(f'Telemetry heartbeat failed: {e}') diff --git a/src/langbot/pkg/telemetry/identity.py b/src/langbot/pkg/telemetry/identity.py new file mode 100644 index 000000000..a1320f1d4 --- /dev/null +++ b/src/langbot/pkg/telemetry/identity.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import typing + + +class WorkspaceExecutionContext(typing.Protocol): + @property + def workspace_uuid(self) -> str: ... + + +def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]: + """Build the canonical telemetry identity for one Workspace execution.""" + workspace_uuid = execution_context.workspace_uuid.strip() + if not workspace_uuid: + raise ValueError('Telemetry execution Workspace UUID is empty') + return {'workspace_uuid': workspace_uuid} diff --git a/src/langbot/pkg/telemetry/telemetry.py b/src/langbot/pkg/telemetry/telemetry.py index 6c3b8ba29..28bcf1a0a 100644 --- a/src/langbot/pkg/telemetry/telemetry.py +++ b/src/langbot/pkg/telemetry/telemetry.py @@ -2,7 +2,11 @@ from __future__ import annotations import asyncio import contextlib +import os +import typing + import httpx + from ..core import app as core_app from ..utils import httpclient @@ -21,7 +25,7 @@ class TelemetryManager: def __init__(self, ap: core_app.Application): self.ap = ap - self.telemetry_config = {} + self.telemetry_config: dict[str, typing.Any] = {} self.send_tasks: list[asyncio.Task] = [] self._client: httpx.AsyncClient | None = None @@ -131,7 +135,16 @@ class TelemetryManager: async with self._client_context() as client: try: # Use asyncio.wait_for to ensure we always bound the total time - resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1) + telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip() + if telemetry_token: + request = client.post( + url, + json=sanitized, + headers={'X-LangBot-Telemetry-Token': telemetry_token}, + ) + else: + request = client.post(url, json=sanitized) + resp = await asyncio.wait_for(request, timeout=10 + 1) if resp.status_code >= 400: body = await httpclient.response_text(resp, max_chars=200) @@ -143,7 +156,8 @@ class TelemetryManager: app_err = False try: j = await httpclient.parse_json_response(resp) - if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400: + app_code = j.get('code') if isinstance(j, dict) else None + if app_code is not None and int(app_code) >= 400: app_err = True self.ap.logger.warning( f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}' diff --git a/src/langbot/pkg/workspace/identity.py b/src/langbot/pkg/workspace/identity.py new file mode 100644 index 000000000..e307d4c06 --- /dev/null +++ b/src/langbot/pkg/workspace/identity.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import uuid + + +_INSTANCE_PREFIX = 'instance_' +_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76') + + +def workspace_uuid_from_instance_id(instance_id: str) -> str: + """Return the stable OSS Workspace UUID for a persisted instance identity.""" + value = instance_id.strip() + if not value: + raise ValueError('LangBot instance identity is empty') + + candidate = value[len(_INSTANCE_PREFIX) :] if value.startswith(_INSTANCE_PREFIX) else value + try: + return str(uuid.UUID(candidate)) + except ValueError: + return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value)) diff --git a/src/langbot/pkg/workspace/service.py b/src/langbot/pkg/workspace/service.py index bab30d4f2..b4eb0a845 100644 --- a/src/langbot/pkg/workspace/service.py +++ b/src/langbot/pkg/workspace/service.py @@ -30,6 +30,7 @@ from .errors import ( WorkspaceOwnerAlreadyExistsError, ) from .entities import WorkspaceExecutionBinding +from .identity import workspace_uuid_from_instance_id from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy from .repository import WorkspaceRepository @@ -497,7 +498,7 @@ class WorkspaceService: created_by_account_uuid: str | None = None, ) -> Workspace: return Workspace( - uuid=str(uuid.uuid4()), + uuid=workspace_uuid_from_instance_id(self.instance_uuid), instance_uuid=self.instance_uuid, name=name, slug=slug, diff --git a/tests/integration/api/test_support_admin_launch.py b/tests/integration/api/test_support_admin_launch.py new file mode 100644 index 000000000..94647a7f1 --- /dev/null +++ b/tests/integration/api/test_support_admin_launch.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import base64 +import datetime +import json +import logging +import time +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from quart import Quart +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from langbot.pkg.api.http.authz import Permission +from langbot.pkg.api.http.context import PrincipalType, RequestContext +from langbot.pkg.api.http.controller import group +from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup +from langbot.pkg.api.http.controller.groups.user import UserRouterGroup +from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService +from langbot.pkg.cloud.support_admin import SupportAdminSessionService +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.support_admin import SupportAdminTemporarySession +from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.workspace import ( + Workspace, + WorkspaceExecutionState, + WorkspaceMembership, +) +from langbot.pkg.workspace.service import WorkspaceService + + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +INSTANCE_UUID = 'instance-support-admin' +WORKSPACE_UUID = '10000000-0000-4000-8000-000000000001' +OTHER_WORKSPACE_UUID = '10000000-0000-4000-8000-000000000002' +ACTOR_ACCOUNT_UUID = '20000000-0000-4000-8000-000000000001' +KEY_ID = 'support-admin-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 _admin_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.support_admin_launch', + 'payload': { + 'workspace_uuid': workspace_uuid, + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': ACTOR_ACCOUNT_UUID, + 'effective_role': 'owner', + }, + } + + +@group.group_class('support_admin_probe', '/api/v1/support-admin-probe') +class SupportAdminProbeGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/user-token', auth_type=group.AuthType.USER_TOKEN, permission=Permission.WORKSPACE_VIEW) + async def _(request_context: RequestContext) -> str: + return self.success(data=_context_payload(request_context)) + + @self.route( + '/member-operation', + auth_type=group.AuthType.USER_TOKEN, + permission=Permission.MEMBER_VIEW, + ) + async def member_operation(request_context: RequestContext) -> str: + return self.success(data=_context_payload(request_context)) + + @self.route( + '/user-token-or-api-key', + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.WORKSPACE_VIEW, + ) + async def _(request_context: RequestContext) -> str: + return self.success(data=_context_payload(request_context)) + + +def _context_payload(request_context: RequestContext) -> dict: + return { + 'principal_type': request_context.principal.principal_type.value, + 'actor_account_uuid': request_context.principal.actor_account_uuid, + 'account_uuid': request_context.principal.account_uuid, + 'role': request_context.workspace.role, + 'membership_uuid': request_context.workspace.membership_uuid, + 'permissions': sorted(request_context.workspace.permissions), + } + + +class _TenantUow: + def __init__(self, engine): + self._engine = engine + self.session = None + self._transaction = None + + async def __aenter__(self): + session_factory = async_sessionmaker(self._engine, expire_on_commit=False) + self.session = session_factory() + self._transaction = await self.session.begin() + return self + + async def __aexit__(self, exc_type, exc, traceback): + try: + if exc_type is None: + await self._transaction.commit() + else: + await self._transaction.rollback() + finally: + await self.session.close() + + +class _TenantScope: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return False + + +class _PersistenceManager: + def __init__(self, engine): + self._engine = engine + self.mode = SimpleNamespace(value='oss_compat') + + def get_db_engine(self): + return self._engine + + def tenant_uow(self, workspace_uuid: str): + del workspace_uuid + return _TenantUow(self._engine) + + def tenant_scope(self, workspace_uuid: str): + del workspace_uuid + return _TenantScope() + + +@pytest.fixture +async def support_admin_api(tmp_path): + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "support-admin.db"}') + async with engine.begin() as connection: + await connection.run_sync( + Base.metadata.create_all, + tables=[ + User.__table__, + Workspace.__table__, + WorkspaceExecutionState.__table__, + WorkspaceMembership.__table__, + SupportAdminTemporarySession.__table__, + ], + ) + for workspace_uuid, slug in ( + (WORKSPACE_UUID, 'support-admin-a'), + (OTHER_WORKSPACE_UUID, 'support-admin-b'), + ): + await connection.execute( + sqlalchemy.insert(Workspace).values( + uuid=workspace_uuid, + instance_uuid=INSTANCE_UUID, + name=slug, + slug=slug, + type='team', + status='active', + source='cloud_projection', + projection_revision=1, + ) + ) + await connection.execute( + sqlalchemy.insert(WorkspaceExecutionState).values( + workspace_uuid=workspace_uuid, + instance_uuid=INSTANCE_UUID, + active_generation=1, + state='active', + write_fenced=False, + source='cloud', + desired_state_revision=1, + ) + ) + + app = SimpleNamespace() + app.persistence_mgr = _PersistenceManager(engine) + app.instance_config = SimpleNamespace( + data={ + 'system': { + 'jwt': {'secret': 'support-admin-secret', 'expire': 3600}, + 'websocket_retention': {}, + }, + 'space': { + 'launch': { + 'control_plane_public_key': _base64url(public_key), + } + }, + 'api': {'global_api_key': ''}, + } + ) + app.logger = logging.getLogger('support-admin-test') + app.deployment = SimpleNamespace(mode='cloud', multi_workspace_enabled=True, verification_key_id=KEY_ID) + app.directory_projection_service = SimpleNamespace(require_ready=lambda: None) + app.workspace_service = WorkspaceService(app, instance_uuid=INSTANCE_UUID) + app.entitlement_resolver = SimpleNamespace( + instance_uuid=INSTANCE_UUID, + resolve=AsyncMock(return_value=SimpleNamespace(entitlement_revision=7)), + ) + app.support_admin_session_service = SupportAdminSessionService(app) + app.space_launch_service = SpaceLaunchService(app) + app.user_service = SimpleNamespace() + app.user_service.get_authenticated_account = AsyncMock(side_effect=AssertionError('normal account auth used')) + app.user_service.verify_jwt_token = AsyncMock(side_effect=AssertionError('normal token verification used')) + app.user_service.get_user_by_email = AsyncMock(side_effect=AssertionError('user lookup used')) + app.apikey_service = SimpleNamespace() + app.apikey_service.authenticate_api_key = AsyncMock( + return_value=SimpleNamespace( + instance_uuid=INSTANCE_UUID, + workspace_uuid=OTHER_WORKSPACE_UUID, + placement_generation=1, + api_key_uuid='api-key', + permissions=frozenset(permission.value for permission in Permission), + ) + ) + + quart_app = Quart(__name__) + await UserRouterGroup(app, quart_app).initialize() + await SupportAdminProbeGroup(app, quart_app).initialize() + + yield app, quart_app.test_client(), engine, private_key + await engine.dispose() + + +async def _issue_support_token(app, private_key: Ed25519PrivateKey, *, jti: str | None = None) -> dict[str, str]: + launch = await app.space_launch_service.consume_assertion( + _sign(private_key, _admin_claims(now=int(time.time()), jti=jti)), + expected_workspace_uuid=WORKSPACE_UUID, + ) + return launch + + +def _auth(token: str, workspace_uuid: str = WORKSPACE_UUID) -> dict[str, str]: + return {'Authorization': f'Bearer {token}', 'X-Workspace-Id': workspace_uuid} + + +async def test_support_admin_membership_only_routes_are_denied(support_admin_api): + app, client, _engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + + response = await client.get( + '/api/v1/support-admin-probe/member-operation', + headers=_auth(launch['support_admin_token']), + ) + + assert response.status_code == 403 + assert (await response.get_json())['code'] == 'permission_denied' + + +async def test_support_admin_check_token_is_rejected(support_admin_api): + app, client, _engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + + response = await client.get('/api/v1/user/check-token', headers=_auth(launch['support_admin_token'])) + + assert response.status_code == 401 + assert (await response.get_json())['code'] == 'invalid_authentication' + + +async def test_support_admin_cross_workspace_denied_for_user_token_and_or_api_key(support_admin_api): + app, client, _engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + + missing_selector = await client.get( + '/api/v1/support-admin-probe/user-token', + headers={'Authorization': f'Bearer {launch["support_admin_token"]}'}, + ) + user_response = await client.get( + '/api/v1/support-admin-probe/user-token', + headers=_auth(launch['support_admin_token'], OTHER_WORKSPACE_UUID), + ) + either_response = await client.get( + '/api/v1/support-admin-probe/user-token-or-api-key', + headers={ + **_auth(launch['support_admin_token'], OTHER_WORKSPACE_UUID), + 'X-API-Key': 'valid-api-key', + }, + ) + + assert missing_selector.status_code == 400 + assert user_response.status_code == 401 + assert either_response.status_code == 401 + app.apikey_service.authenticate_api_key.assert_not_awaited() + + +async def test_support_admin_request_context_has_actor_owner_and_no_membership(support_admin_api): + app, client, engine, private_key = support_admin_api + before_count = await _membership_count(engine) + launch = await _issue_support_token(app, private_key) + + response = await client.get( + '/api/v1/support-admin-probe/user-token', + headers=_auth(launch['support_admin_token']), + ) + + assert response.status_code == 200 + data = (await response.get_json())['data'] + permissions = set(data.pop('permissions')) + assert Permission.WORKSPACE_VIEW.value in permissions + assert Permission.RESOURCE_MANAGE.value in permissions + assert not permissions.intersection( + { + Permission.OWNER_TRANSFER.value, + Permission.MEMBER_VIEW.value, + Permission.MEMBER_INVITE.value, + Permission.MEMBER_UPDATE_ROLE.value, + Permission.MEMBER_REMOVE.value, + } + ) + assert data == { + 'principal_type': PrincipalType.SUPPORT_ADMIN.value, + 'actor_account_uuid': ACTOR_ACCOUNT_UUID, + 'account_uuid': None, + 'role': 'owner', + 'membership_uuid': None, + } + assert await _membership_count(engine) == before_count + + +async def test_support_admin_missing_workspace_is_controlled_launch_failure(support_admin_api): + app, _client, engine, private_key = support_admin_api + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.delete(WorkspaceExecutionState).where(WorkspaceExecutionState.workspace_uuid == WORKSPACE_UUID) + ) + + with pytest.raises(SpaceLaunchError, match='unavailable'): + await _issue_support_token(app, private_key) + + +async def test_support_admin_launch_replay_is_durable_across_service_instances(support_admin_api): + app, _client, _engine, private_key = support_admin_api + jti = str(uuid.uuid4()) + + await _issue_support_token(app, private_key, jti=jti) + second_service = SpaceLaunchService(app) + + with pytest.raises(SpaceLaunchError, match='already been consumed'): + await second_service.consume_assertion( + _sign(private_key, _admin_claims(now=int(time.time()), jti=jti)), + expected_workspace_uuid=WORKSPACE_UUID, + ) + + +async def test_support_admin_persisted_expiry_and_revocation_are_enforced(support_admin_api): + app, client, engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + token = launch['support_admin_token'] + + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.update(SupportAdminTemporarySession) + .where(SupportAdminTemporarySession.grant_jti_hash == launch['grant_jti_hash']) + .values(expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(minutes=1)) + ) + expired = await client.get('/api/v1/support-admin-probe/user-token', headers=_auth(token)) + assert expired.status_code == 401 + + second = await _issue_support_token(app, private_key) + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.update(SupportAdminTemporarySession) + .where(SupportAdminTemporarySession.grant_jti_hash == second['grant_jti_hash']) + .values(revoked_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None)) + ) + revoked = await client.get('/api/v1/support-admin-probe/user-token', headers=_auth(second['support_admin_token'])) + assert revoked.status_code == 401 + + +async def test_support_admin_websocket_preserves_actor_and_revalidates(support_admin_api): + app, _client, _engine, private_key = support_admin_api + launch = await _issue_support_token(app, private_key) + captured_contexts = [] + + class Adapter: + async def handle_websocket_message(self, connection, data): + del data + captured_contexts.append(connection.execution_context) + await connection.send_queue.put({'type': 'handled'}) + connection.is_active = False + + app.pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=SimpleNamespace(uuid='pipeline-1'))) + app.platform_mgr = SimpleNamespace( + get_websocket_proxy_bot=AsyncMock(return_value=SimpleNamespace(adapter=Adapter())) + ) + + quart_app = Quart(__name__) + await WebSocketChatRouterGroup(app, quart_app).initialize() + + async with quart_app.test_client().websocket('/api/v1/pipelines/pipeline-1/ws/connect') as websocket: + await websocket.send( + json.dumps( + { + 'type': 'authenticate', + 'token': launch['support_admin_token'], + 'workspace_uuid': WORKSPACE_UUID, + } + ) + ) + connected = json.loads(await websocket.receive()) + assert connected['type'] == 'connected' + await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]})) + handled = json.loads(await websocket.receive()) + assert handled['type'] == 'handled' + + assert captured_contexts + principal = captured_contexts[0].trigger_principal + assert principal is not None + assert principal.principal_type == PrincipalType.SUPPORT_ADMIN + assert principal.actor_account_uuid == ACTOR_ACCOUNT_UUID + + +async def _membership_count(engine) -> int: + async with engine.connect() as connection: + return int( + await connection.scalar( + sqlalchemy.select(sqlalchemy.func.count()).select_from(WorkspaceMembership), + ) + or 0 + ) diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 17569707c..deb1e93e2 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -270,7 +270,7 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): response = await client.get( '/api/v1/user/space-credits', - headers={'Authorization': 'Bearer account-token', 'X-Workspace-UUID': WORKSPACE_UUID}, + headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, ) assert response.status_code == 200 diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py index 12b42b50f..22b0f28e1 100644 --- a/tests/integration/persistence/test_workspace_migration.py +++ b/tests/integration/persistence/test_workspace_migration.py @@ -22,6 +22,7 @@ from langbot.pkg.persistence.alembic_runner import ( from langbot.pkg.utils import constants from langbot.pkg.utils import importutil from langbot.pkg.workspace.collaboration import normalize_email +from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id pytestmark = [pytest.mark.integration, pytest.mark.asyncio] @@ -109,6 +110,7 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy .mappings() .one() ) + assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test') assert workspace['instance_uuid'] == 'instance_migration_test' assert workspace['slug'] == 'default' assert workspace['status'] == 'active' @@ -153,11 +155,11 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega await run_alembic_upgrade(legacy_engine, '0016_agent_workspace') async with legacy_engine.begin() as conn: owner_account_uuid = await conn.scalar(sa.text('SELECT uuid FROM users ORDER BY id LIMIT 1')) - workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) + old_workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) await conn.execute(sa.text('DELETE FROM workspace_memberships')) await conn.execute( sa.text('UPDATE workspaces SET created_by_account_uuid = NULL WHERE uuid = :workspace_uuid'), - {'workspace_uuid': workspace_uuid}, + {'workspace_uuid': old_workspace_uuid}, ) await run_alembic_upgrade(legacy_engine, 'head') @@ -165,19 +167,66 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega async with legacy_engine.connect() as conn: workspace = ( await conn.execute( - sa.text('SELECT created_by_account_uuid FROM workspaces WHERE uuid = :workspace_uuid'), - {'workspace_uuid': workspace_uuid}, + sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"), ) ).mappings().one() membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one() + assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test') assert workspace['created_by_account_uuid'] == owner_account_uuid - assert membership['workspace_uuid'] == workspace_uuid + assert membership['workspace_uuid'] == workspace['uuid'] assert membership['account_uuid'] == owner_account_uuid assert membership['role'] == 'owner' assert membership['status'] == 'active' +async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') + instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f' + old_workspace_uuid = '11111111-1111-4111-8111-111111111111' + canonical_uuid = workspace_uuid_from_instance_id(instance_id) + schema = sa.MetaData() + sa.Table( + 'metadata', + schema, + sa.Column('key', sa.String(255), primary_key=True), + sa.Column('value', sa.String(255)), + ) + sa.Table( + 'workspaces', + schema, + sa.Column('uuid', sa.String(36), primary_key=True), + sa.Column('instance_uuid', sa.String(255), nullable=False), + sa.Column('slug', sa.String(255), nullable=False), + sa.Column('source', sa.String(32), nullable=False), + ) + sa.Table( + 'tenant_rows', + schema, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False), + ) + async with engine.begin() as conn: + await conn.run_sync(schema.create_all) + await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}) + await conn.execute( + sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"), + {'uuid': old_workspace_uuid, 'instance': instance_id}, + ) + await conn.execute( + sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"), + {'uuid': old_workspace_uuid}, + ) + await run_alembic_stamp(engine, '0016_support_admin_sessions') + + await run_alembic_upgrade(engine, 'head') + + async with engine.connect() as conn: + assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid + assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid + await engine.dispose() + + async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}') try: @@ -391,6 +440,47 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade await engine.dispose() +async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') + try: + await _create_legacy_schema(engine) + await run_alembic_stamp(engine, '0008_mcp_resource_prefs') + await run_alembic_upgrade(engine, '0016_support_admin_sessions') + + async with engine.begin() as conn: + old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) + instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'")) + assert old_uuid + assert instance_uuid + await conn.execute( + sa.text( + "INSERT INTO workspace_metadata (workspace_uuid, key, value) " + "VALUES (:workspace_uuid, 'migration_probe', 'present')" + ), + {'workspace_uuid': old_uuid}, + ) + await conn.execute( + sa.text( + "INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value" + ), + {'workspace_uuid': old_uuid}, + ) + + await run_alembic_upgrade(engine, 'head') + expected_uuid = workspace_uuid_from_instance_id(instance_uuid) + async with engine.connect() as conn: + assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid + assert await conn.scalar( + sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'") + ) == expected_uuid + assert await conn.scalar( + sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'") + ) == expected_uuid + finally: + await engine.dispose() + + async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}') try: diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index 96d501b95..a3479725e 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -418,6 +418,7 @@ class TestUserServiceGenerateJwtToken: assert token is not None + class TestUserServiceVerifyJwtToken: """Tests for verify_jwt_token method.""" diff --git a/tests/unit_tests/api/test_adapter_session_scoping.py b/tests/unit_tests/api/test_adapter_session_scoping.py index 3e8642aad..4eb28d1b4 100644 --- a/tests/unit_tests/api/test_adapter_session_scoping.py +++ b/tests/unit_tests/api/test_adapter_session_scoping.py @@ -149,6 +149,36 @@ async def test_session_scope_matches_exact_tenant_placement_and_principal(): assert sessions == {} +async def test_support_admin_sessions_are_scoped_to_the_persisted_grant(): + def support_context(grant_jti_hash: str) -> RequestContext: + return RequestContext( + instance_uuid='instance-test', + placement_generation=1, + request_id='request-test', + auth_type='support-admin', + principal=PrincipalContext( + principal_type=PrincipalType.SUPPORT_ADMIN, + actor_account_uuid='support-actor', + support_session_id=grant_jti_hash, + ), + workspace=WorkspaceContext( + workspace_uuid='workspace-a', + membership_uuid=None, + role='owner', + permissions=frozenset({'resource.manage'}), + ), + ) + + first_context = support_context('a' * 64) + second_context = support_context('b' * 64) + sessions: dict[str, dict] = {'session-test': {'status': 'waiting'}} + _bind_session_scope(sessions['session-test'], first_context) + + assert _get_owned_session(sessions, 'session-test', second_context) is None + assert _pop_owned_session(sessions, 'session-test', second_context) is None + assert _get_owned_session(sessions, 'session-test', first_context) is sessions['session-test'] + + async def test_session_capacity_evicts_oldest_session_in_same_workspace(): owner_context = _request_context() sessions: dict[str, dict] = {} diff --git a/tests/unit_tests/api/test_plugin_runtime_route_fence.py b/tests/unit_tests/api/test_plugin_runtime_route_fence.py index 0bc324e83..3594a7374 100644 --- a/tests/unit_tests/api/test_plugin_runtime_route_fence.py +++ b/tests/unit_tests/api/test_plugin_runtime_route_fence.py @@ -46,6 +46,20 @@ def plugin_router_cls(): yield PluginsRouterGroup +@pytest.mark.asyncio +async def test_authenticated_plugin_resource_fences_injected_workspace_context(plugin_router_cls): + connector = SimpleNamespace( + require_workspace_context=AsyncMock(return_value=CONTEXT), + ) + router = object.__new__(plugin_router_cls) + router.ap = SimpleNamespace(plugin_connector=connector) + + result = await router._require_authenticated_plugin_runtime_context(CONTEXT) + + assert result == CONTEXT + connector.require_workspace_context.assert_awaited_once_with(CONTEXT) + + @pytest.mark.asyncio async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls): connector = SimpleNamespace( diff --git a/tests/unit_tests/cloud/test_space_launch.py b/tests/unit_tests/cloud/test_space_launch.py index 14b6b7f17..be9c7c11a 100644 --- a/tests/unit_tests/cloud/test_space_launch.py +++ b/tests/unit_tests/cloud/test_space_launch.py @@ -11,6 +11,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService +from langbot.pkg.cloud.support_admin import SupportAdminReplayError pytestmark = pytest.mark.asyncio @@ -57,9 +58,21 @@ def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService: encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) + consumed: set[str] = set() + + class DurableSupportAdminService: + async def consume_launch_grant(self, **kwargs): + grant_hash = kwargs['grant_jti_hash'] + if grant_hash in consumed: + raise SupportAdminReplayError('already consumed') + consumed.add(grant_hash) + return SimpleNamespace(token='support-admin-token') + app = SimpleNamespace( deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID), workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID), + logger=SimpleNamespace(info=lambda *args, **kwargs: None), + support_admin_session_service=DurableSupportAdminService(), instance_config=SimpleNamespace( data={ 'space': { @@ -86,6 +99,81 @@ async def test_consumes_valid_workspace_launch_assertion_once(): await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + +async def test_consumes_admin_owner_launch_once_and_validates_claims(): + private_key = Ed25519PrivateKey.generate() + now = int(time.time()) + service = _service(private_key, now=now) + claims = _claims(now=now) + claims['kind'] = 'workspace.support_admin_launch' + claims['payload'].update( + { + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'owner', + } + ) + claims['payload'].pop('account_uuid') + token = _sign(private_key, claims) + + launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + assert launch == { + 'workspace_uuid': WORKSPACE_UUID, + 'launch_mode': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'owner', + 'grant_jti_hash': launch['grant_jti_hash'], + 'support_admin_token': 'support-admin-token', + } + with pytest.raises(SpaceLaunchError, match='already been consumed'): + await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + invalid = _claims(now=now) + invalid['kind'] = 'workspace.support_admin_launch' + invalid['payload'].update( + { + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'member', + } + ) + invalid['payload'].pop('account_uuid') + with pytest.raises(SpaceLaunchError, match='effective role'): + await service.consume_assertion(_sign(private_key, invalid), expected_workspace_uuid=WORKSPACE_UUID) + + too_long = _claims(now=now) + too_long['kind'] = 'workspace.support_admin_launch' + too_long['exp'] = now + 91 + too_long['payload'].update( + { + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'owner', + } + ) + too_long['payload'].pop('account_uuid') + with pytest.raises(SpaceLaunchError, match='lifetime exceeds 90 seconds'): + await service.consume_assertion(_sign(private_key, too_long), expected_workspace_uuid=WORKSPACE_UUID) + + impersonating = _claims(now=now) + impersonating['kind'] = 'workspace.support_admin_launch' + impersonating['payload'].update( + { + 'launch_mode': 'support_admin', + 'principal_type': 'support_admin', + 'actor_account_uuid': '33333333-3333-4333-8333-333333333333', + 'effective_role': 'owner', + } + ) + with pytest.raises(SpaceLaunchError, match='customer Account'): + await service.consume_assertion(_sign(private_key, impersonating), expected_workspace_uuid=WORKSPACE_UUID) + + async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch): private_key = Ed25519PrivateKey.generate() now = int(time.time()) diff --git a/tests/unit_tests/platform/test_telegram_adapter.py b/tests/unit_tests/platform/test_telegram_adapter.py index 885310914..bc7af1bf5 100644 --- a/tests/unit_tests/platform/test_telegram_adapter.py +++ b/tests/unit_tests/platform/test_telegram_adapter.py @@ -1,7 +1,8 @@ """Tests for Telegram Dify form callback helpers.""" import json -from unittest.mock import AsyncMock, MagicMock +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch import pytest from telegram import ForceReply @@ -9,8 +10,10 @@ from telegram import ForceReply import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.message as platform_message + from langbot.pkg.platform.sources.telegram import ( TelegramAdapter, + TelegramMessageConverter, _decode_telegram_base64_limited, _telegram_form_action_from_callback, _telegram_select_field_options, @@ -26,6 +29,70 @@ def test_telegram_base64_decode_is_bounded(monkeypatch): _decode_telegram_base64_limited('A' * 12) +TELEGRAM_BOT_TOKEN = '123456789:AAExampleBotTokenThatMustNotLeak' +TELEGRAM_FILE_URL = f'https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}/photos/file_0.jpg' + + +@pytest.mark.asyncio +async def test_telegram_photo_does_not_expose_bot_token_in_image_url(): + """Regression test for the Telegram bot-token leak. + + telegram.Bot builds file.file_path as + https://api.telegram.org/file/bot/, embedding the bot token. + The converter must not copy that URL into Image.url, or the token leaks to + the monitoring DB, dashboard and every installed plugin via the message + chain. Only base64 (which carries no token) may be stored. + """ + tg_file = MagicMock() + tg_file.file_path = TELEGRAM_FILE_URL + + photo_size = MagicMock() + photo_size.get_file = AsyncMock(return_value=tg_file) + + message = MagicMock() + message.text = None + message.caption = None + message.photo = [photo_size] + message.voice = None + message.document = None + + response = MagicMock() + response.headers = {} + + async def iter_chunked(_chunk_size): + yield b'\xff\xd8\xff\xe0jpeg-bytes' + + response.content.iter_chunked = iter_chunked + + @asynccontextmanager + async def fake_get(url): + yield response + + fake_session = MagicMock() + fake_session.get = fake_get + + with patch( + 'langbot.pkg.platform.sources.telegram.httpclient.get_session', + return_value=fake_session, + ): + chain = await TelegramMessageConverter.target2yiri(message, MagicMock(), 'bot-account') + + images = [c for c in chain if isinstance(c, platform_message.Image)] + assert len(images) == 1 + image = images[0] + + # The token-bearing URL must not be retained anywhere on the component. + assert not image.url + assert image.base64 is not None + assert image.base64.startswith('data:image/jpeg;base64,') + + # Belt-and-suspenders: the token must not appear in the serialized chain + # (this is what gets persisted to the monitoring DB and sent to plugins). + serialized = json.dumps(chain.model_dump(), ensure_ascii=False) + assert TELEGRAM_BOT_TOKEN not in serialized + assert 'api.telegram.org/file/bot' not in serialized + + def _select_form_data() -> dict: return { '_current_input_field': 'choice', diff --git a/tests/unit_tests/telemetry/test_heartbeat.py b/tests/unit_tests/telemetry/test_heartbeat.py index a8dfae936..3d7e60620 100644 --- a/tests/unit_tests/telemetry/test_heartbeat.py +++ b/tests/unit_tests/telemetry/test_heartbeat.py @@ -60,10 +60,12 @@ class TestBuildHeartbeatPayload: async def test_payload_shape(self): heartbeat = get_heartbeat_module() ap = make_app() - payload = await heartbeat.build_heartbeat_payload(ap) + payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a') assert payload['event_type'] == 'instance_heartbeat' assert payload['query_id'] == '' + assert payload['workspace_uuid'] == 'workspace-a' + assert 'instance_id' not in payload assert 'instance_create_ts' in payload assert 'timestamp' in payload f = payload['features'] @@ -86,7 +88,7 @@ class TestBuildHeartbeatPayload: @pytest.mark.asyncio async def test_payload_is_json_serializable(self): heartbeat = get_heartbeat_module() - payload = await heartbeat.build_heartbeat_payload(make_app()) + payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a') json.dumps(payload) @pytest.mark.asyncio @@ -94,7 +96,7 @@ class TestBuildHeartbeatPayload: heartbeat = get_heartbeat_module() ap = make_app() ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down')) - payload = await heartbeat.build_heartbeat_payload(ap) + payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a') assert payload['features']['pipeline_count'] == -1 @pytest.mark.asyncio @@ -106,31 +108,73 @@ class TestBuildHeartbeatPayload: side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs') ) ap.pipeline_mgr = SimpleNamespace( - _pipelines_by_key={'pipeline-a': object(), 'pipeline-b': object()}, + _pipelines_by_key={ + ('instance-a', 'workspace-a', 'pipeline-a'): object(), + ('instance-a', 'workspace-a', 'pipeline-b'): object(), + }, ) + adapter_a = type('WorkspaceAAdapter', (), {})() + adapter_b = type('WorkspaceBAdapter', (), {})() + ap.platform_mgr._bots_by_key = { + ('instance-a', 'workspace-a', 'bot-a'): SimpleNamespace(enable=True, adapter=adapter_a), + } ap.tool_mgr = SimpleNamespace( mcp_tool_loader=SimpleNamespace( - _sessions={'mcp-a': object(), 'mcp-b': object(), 'mcp-c': object()}, + _sessions={ + ('instance-a', 'workspace-a', 1, 'mcp-a'): object(), + ('instance-a', 'workspace-a', 1, 'mcp-b'): object(), + ('instance-a', 'workspace-a', 1, 'mcp-c'): object(), + }, ), ) ap.rag_mgr = SimpleNamespace( - knowledge_bases={'kb-a': object()}, + knowledge_bases={('workspace-a', 'kb-a'): object()}, + ) + ap.plugin_connector._workspace_installations = { + 'workspace-a': {'plugin-a', 'plugin-b'}, + } + ap.skill_mgr._skills_by_scope = { + ('instance-a', 'workspace-a', 1): {'skill-a': {}, 'skill-b': {}}, + ('instance-a', 'workspace-b', 1): {'skill-c': {}}, + } + ap.workspace_service.list_active_execution_bindings = AsyncMock( + return_value=[ + SimpleNamespace(workspace_uuid='workspace-a', placement_generation=7), + SimpleNamespace(workspace_uuid='workspace-b', placement_generation=9), + ], + ) + ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace( + enable=True, adapter=adapter_b ) - payload = await heartbeat.build_heartbeat_payload(ap) + payloads = await heartbeat.build_heartbeat_payloads(ap) - features = payload['features'] - assert features['pipeline_count'] == 2 - assert features['mcp_server_count'] == 3 - assert features['knowledge_base_count'] == 1 - assert features['bot_count'] == 1 + assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b'] + assert all('instance_id' not in payload for payload in payloads) + by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads} + assert by_workspace['workspace-a']['pipeline_count'] == 2 + assert by_workspace['workspace-a']['mcp_server_count'] == 3 + assert by_workspace['workspace-a']['knowledge_base_count'] == 1 + assert by_workspace['workspace-a']['bot_count'] == 1 + assert by_workspace['workspace-a']['plugin_count'] == 2 + assert by_workspace['workspace-a']['extension_count'] == 5 + assert by_workspace['workspace-a']['skill_count'] == 2 + assert by_workspace['workspace-a']['execution_generation'] == 7 + assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter'] + assert by_workspace['workspace-b']['bot_count'] == 1 + assert by_workspace['workspace-b']['pipeline_count'] == 0 + assert by_workspace['workspace-b']['skill_count'] == 1 + assert by_workspace['workspace-b']['execution_generation'] == 9 + assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter'] + assert 'workspace_resources' not in by_workspace['workspace-a'] ap.persistence_mgr.execute_async.assert_not_awaited() + ap.workspace_service.list_active_execution_bindings.assert_awaited_once() @pytest.mark.asyncio async def test_no_user_content_fields(self): """The heartbeat must never carry message content / credentials keys.""" heartbeat = get_heartbeat_module() - payload = await heartbeat.build_heartbeat_payload(make_app()) + payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a') flat = json.dumps(payload).lower() for forbidden in ('api_key', 'password', 'token', 'message_content'): assert forbidden not in flat diff --git a/tests/unit_tests/telemetry/test_telemetry.py b/tests/unit_tests/telemetry/test_telemetry.py index b15a989ee..2242a1aa4 100644 --- a/tests/unit_tests/telemetry/test_telemetry.py +++ b/tests/unit_tests/telemetry/test_telemetry.py @@ -569,6 +569,33 @@ class TestHTTPScenarios: await manager.send({'query_id': 'test'}) +class TestTelemetryManagedRuntimeAuthentication: + @pytest.mark.asyncio + async def test_send_includes_managed_runtime_token_header(self): + telemetry = get_telemetry_module() + mock_app = Mock() + mock_app.logger = Mock() + manager = telemetry.TelemetryManager(mock_app) + manager.telemetry_config = {'url': 'https://example.com'} + captured = {} + + async def mock_post(url, json, headers): + captured['headers'] = headers + return Mock(status_code=200, text='', json=Mock(return_value={'code': 0})) + + mock_client = Mock() + mock_client.post = mock_post + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + with ( + patch.dict('os.environ', {'LANGBOT_TELEMETRY_INGEST_TOKEN': 'managed-runtime-secret'}), + patch.object(httpx, 'AsyncClient', return_value=mock_client), + ): + await manager.send({'event_type': 'instance_heartbeat'}) + + assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'} + + class TestStartSendTask: """Tests for start_send_task() method.""" diff --git a/tests/unit_tests/telemetry/test_workspace_identity.py b/tests/unit_tests/telemetry/test_workspace_identity.py new file mode 100644 index 000000000..efbd68a11 --- /dev/null +++ b/tests/unit_tests/telemetry/test_workspace_identity.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace + + +def test_standard_oss_instance_id_aligns_to_embedded_uuid(): + from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id + + instance_uuid = "a711d9e4-0953-443f-a0e9-7dd50193a79f" + + assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid + assert workspace_uuid_from_instance_id(f"instance_{instance_uuid}") == instance_uuid + + +def test_custom_legacy_instance_id_maps_to_stable_valid_uuid(): + from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id + + first = workspace_uuid_from_instance_id("instance_migration_test") + second = workspace_uuid_from_instance_id("instance_migration_test") + + assert first == second + assert str(uuid.UUID(first)) == first + + +def test_query_telemetry_identity_uses_execution_workspace_only(): + from langbot.pkg.telemetry.identity import workspace_identity + + identity = workspace_identity(SimpleNamespace(workspace_uuid="workspace-a", instance_uuid="instance-a")) + + assert identity == {"workspace_uuid": "workspace-a"} diff --git a/tests/unit_tests/workspace/test_workspace_service.py b/tests/unit_tests/workspace/test_workspace_service.py index 8657998a3..f24f2ca44 100644 --- a/tests/unit_tests/workspace/test_workspace_service.py +++ b/tests/unit_tests/workspace/test_workspace_service.py @@ -26,6 +26,7 @@ from langbot.pkg.workspace import ( WorkspaceOwnerAlreadyExistsError, WorkspaceService, ) +from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id from langbot.pkg.workspace.policy import CloudWorkspacePolicy @@ -113,6 +114,7 @@ async def test_ensure_singleton_workspace_is_idempotent(workspace_test_context): first = await service.ensure_singleton_workspace() second = await service.ensure_singleton_workspace() + assert first.uuid == workspace_uuid_from_instance_id('instance_service_test') assert second.uuid == first.uuid async with session_factory() as session: assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1 diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index 33f3a4846..1028de6ac 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { httpClient } from '@/app/infra/http/HttpClient'; import { beginAuthenticatedSession, + beginSupportAdminSession, bootstrapWorkspaceSession, getPendingInvitationToken, } from '@/app/infra/http'; @@ -27,8 +28,10 @@ import langbotIcon from '@/app/assets/langbot-logo.webp'; type SpaceOAuthLoginResult = { token: string; - user: string; + user?: string; workspace_uuid?: string; + principal_type?: 'account' | 'support_admin'; + actor_account_uuid?: string; }; const pendingSpaceOAuthLogins = new Map< @@ -94,6 +97,16 @@ function SpaceOAuthCallbackContent() { return; } + if (response.principal_type === 'support_admin') { + if (!response.workspace_uuid) { + throw new Error('Support admin launch did not include a Workspace'); + } + beginSupportAdminSession(response.token, response.workspace_uuid); + await bootstrapWorkspaceSession(); + navigate('/home', { replace: true }); + return; + } + beginAuthenticatedSession(response.token, response.user); if (getPendingInvitationToken()) { navigate('/invitations/accept', { replace: true }); diff --git a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx index 472b3507b..747fd19e5 100644 --- a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx +++ b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx @@ -197,6 +197,19 @@ export function SidebarDataProvider({ // Deduplicate plugins by composite key (prefer debug over installed) const pluginMap = new Map(); + const pluginIconURLs = new Map( + await Promise.all( + pluginsResp.plugins.map(async (plugin) => { + const meta = plugin.manifest.manifest.metadata; + const author = meta.author ?? ''; + const name = meta.name; + const url = await httpClient + .getAuthenticatedPluginIconURL(author, name) + .catch(() => ''); + return [`${author}/${name}`, url] as const; + }), + ), + ); for (const plugin of pluginsResp.plugins) { const meta = plugin.manifest.manifest.metadata; const author = meta.author ?? ''; @@ -215,7 +228,7 @@ export function SidebarDataProvider({ const item: SidebarEntityItem = { id: compositeKey, name: extractI18nObject(meta.label), - iconURL: httpClient.getPluginIconURL(author, name), + iconURL: pluginIconURLs.get(compositeKey) || '', installSource: plugin.install_source, installInfo: plugin.install_info, hasUpdate, @@ -249,7 +262,7 @@ export function SidebarDataProvider({ pluginAuthor: author, pluginName: name, pluginLabel: label, - pluginIconURL: httpClient.getPluginIconURL(author, name), + pluginIconURL: pluginIconURLs.get(`${author}/${name}`) || '', pageId: page.id, path: page.path, }); diff --git a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx index 398a97f8c..c585e0ff9 100644 --- a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx +++ b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx @@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; +import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon'; import { Input } from '@/components/ui/input'; import EmojiPicker from '@/components/ui/emoji-picker'; import { @@ -428,12 +429,9 @@ export default function KBForm({ ); return (
- @@ -459,12 +457,9 @@ export default function KBForm({ value={engine.plugin_id} >
- {extractI18nObject(engine.name)} diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index ad949687d..625a90e98 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -17,6 +17,7 @@ import { bootstrapWorkspaceSession, systemInfo, initializeSystemInfo, + isSupportAdminSession, useCurrentWorkspace, } from '@/app/infra/http'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -157,7 +158,7 @@ export default function HomeLayout({ // selected Workspace's wizard state. useEffect(() => { if (!identityReady) return; - if (systemInfo.wizard_status === 'none') { + if (systemInfo?.wizard_status === 'none' && !isSupportAdminSession()) { navigate('/wizard', { replace: true }); } }, [identityReady, navigate]); diff --git a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx index 781b92b35..e3c030091 100644 --- a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx @@ -13,7 +13,7 @@ import { Puzzle, } from 'lucide-react'; import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http'; -import { httpClient } from '@/app/infra/http/HttpClient'; +import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { @@ -39,6 +39,11 @@ export default function ExtensionCardComponent({ const { t } = useTranslation(); const [dropdownOpen, setDropdownOpen] = useState(false); const [iconFailed, setIconFailed] = useState(false); + const authenticatedIcon = useAuthenticatedPluginIcon( + cardVO.author, + cardVO.name, + cardVO.type === 'plugin', + ); const FallbackIcon = cardVO.type === 'mcp' @@ -47,8 +52,8 @@ export default function ExtensionCardComponent({ ? Sparkles : Puzzle; const iconSrc = - cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name); - const showFallback = iconFailed || !iconSrc; + cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL; + const showFallback = iconFailed || authenticatedIcon.error || !iconSrc; const getTypeLabel = (type: ExtensionType) => { switch (type) { diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx index 4f69c51e7..a8a5dd2ad 100644 --- a/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx @@ -10,6 +10,74 @@ import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import { getAPILanguageCode } from '@/i18n/I18nProvider'; import '@/styles/github-markdown.css'; +import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource'; + +function AuthenticatedReadmeImage({ + author, + name, + filepath, + alt, + ...props +}: { + author: string; + name: string; + filepath: string; + alt?: string; +} & React.ImgHTMLAttributes) { + const { url, error } = useAuthenticatedPluginAsset(author, name, filepath); + if (error) + return ( + {alt || filepath} + ); + if (!url) + return ( + + ); + return ( + {alt + ); +} + +function PluginReadmeImage({ + author, + name, + src, + alt, + ...props +}: { + author: string; + name: string; + src?: string; + alt?: string; +} & React.ImgHTMLAttributes) { + const imageSrc = typeof src === 'string' ? src : ''; + if (!imageSrc || /^(https?:\/\/|data:)/i.test(imageSrc)) { + return ( + {alt + ); + } + let filepath = imageSrc.replace(/^(\.\/|\/)+/, ''); + filepath = filepath.replace(/^assets\//, ''); + return ( + + ); +} export default function PluginReadme({ pluginAuthor, @@ -71,49 +139,15 @@ export default function PluginReadme({
    {children}
), li: ({ children }) =>
  • {children}
  • , - img: ({ src, alt, ...props }) => { - let imageSrc = src || ''; - - if (typeof imageSrc !== 'string') { - return ( - {alt - ); - } - - if ( - imageSrc && - !imageSrc.startsWith('http://') && - !imageSrc.startsWith('https://') && - !imageSrc.startsWith('data:') - ) { - imageSrc = imageSrc.replace(/^(\.\/|\/)+/, ''); - - if (!imageSrc.startsWith('assets/')) { - imageSrc = `assets/${imageSrc}`; - } - - const assetPath = imageSrc.replace(/^assets\//, ''); - imageSrc = httpClient.getPluginAssetURL( - pluginAuthor, - pluginName, - assetPath, - ); - } - - return ( - {alt - ); - }, + img: ({ src, alt, ...props }) => ( + + ), }} > {readme} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 13cc14d88..c722be1d4 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -776,6 +776,32 @@ export class BackendClient extends BaseHttpClient { ); } + private async getAuthenticatedObjectURL(path: string): Promise { + const response = await this.instance.get(path, { + responseType: 'blob', + }); + return URL.createObjectURL(response.data); + } + + public getAuthenticatedPluginAssetURL( + author: string, + name: string, + filepath: string, + ): Promise { + return this.getAuthenticatedObjectURL( + `/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`, + ); + } + + public getAuthenticatedPluginIconURL( + author: string, + name: string, + ): Promise { + return this.getAuthenticatedObjectURL( + `/api/v1/plugins/${author}/${name}/authenticated-icon`, + ); + } + public async pluginPageApi( author: string, name: string, @@ -1400,8 +1426,10 @@ export class BackendClient extends BaseHttpClient { launchAssertion?: string, ): Promise<{ token: string; - user: string; + user?: string; workspace_uuid?: string; + principal_type?: 'account' | 'support_admin'; + actor_account_uuid?: string; }> { const response = await this.instance.post( '/api/v1/user/space/callback', diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts index 6223144bb..0014bc272 100644 --- a/web/src/app/infra/http/index.ts +++ b/web/src/app/infra/http/index.ts @@ -217,10 +217,34 @@ export function beginAuthenticatedSession( if (typeof window === 'undefined') return; localStorage.removeItem('token'); localStorage.removeItem('userEmail'); + localStorage.removeItem('authPrincipalType'); localStorage.setItem('token', token); if (userEmail) localStorage.setItem('userEmail', userEmail); } +export function beginSupportAdminSession( + token: string, + workspaceUuid: string, +): void { + userInfo = null; + clearWorkspaceSelection(); + clearWorkspaceBootstrapSnapshot(); + + if (typeof window === 'undefined') return; + localStorage.removeItem('token'); + localStorage.removeItem('userEmail'); + localStorage.setItem('token', token); + localStorage.setItem('authPrincipalType', 'support_admin'); + setActiveWorkspaceUuid(workspaceUuid); +} + +export function isSupportAdminSession(): boolean { + return ( + typeof window !== 'undefined' && + localStorage.getItem('authPrincipalType') === 'support_admin' + ); +} + async function initializeSelectedWorkspace( workspaceUuid: string, workspaces: WorkspaceBootstrapEntry[], @@ -252,6 +276,27 @@ async function initializeSelectedWorkspace( export async function bootstrapWorkspaceSession( options: WorkspaceBootstrapOptions = {}, ): Promise { + if (isSupportAdminSession()) { + const selectedWorkspaceUuid = getActiveWorkspaceUuid(); + if (!selectedWorkspaceUuid) { + throw new Error('Support admin session is missing its Workspace scope'); + } + if ( + options.preferredWorkspaceUuid && + options.preferredWorkspaceUuid !== selectedWorkspaceUuid + ) { + throw new Error('Support admin session cannot change Workspace scope'); + } + await initializeWorkspaceInfo(); + const workspace = getCurrentWorkspaceSnapshot(); + if (!workspace || workspace.workspace.uuid !== selectedWorkspaceUuid) { + clearWorkspaceSelection(); + throw new Error('Support admin Workspace scope could not be initialized'); + } + clearWorkspaceBootstrapSnapshot(); + return { status: 'ready', workspace, workspaces: [] }; + } + if (options.resetSelection) { clearWorkspaceSelection(); clearWorkspaceBootstrapSnapshot(); @@ -339,6 +384,9 @@ export const clearUserInfo = (): void => { userInfo = null; clearWorkspaceSelection(); clearWorkspaceBootstrapSnapshot(); + if (typeof window !== 'undefined') { + localStorage.removeItem('authPrincipalType'); + } }; export { diff --git a/web/src/components/AuthenticatedPluginIcon.tsx b/web/src/components/AuthenticatedPluginIcon.tsx new file mode 100644 index 000000000..fd4055cf4 --- /dev/null +++ b/web/src/components/AuthenticatedPluginIcon.tsx @@ -0,0 +1,32 @@ +import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource'; +import { cn } from '@/lib/utils'; + +export function AuthenticatedPluginIcon({ + author, + name, + alt = '', + className, +}: { + author: string; + name: string; + alt?: string; + className?: string; +}) { + const icon = useAuthenticatedPluginIcon( + author, + name, + Boolean(author && name), + ); + + if (!icon.url || icon.error) { + return ( + + ); + } + + return {alt}; +} diff --git a/web/src/hooks/useAuthenticatedPluginResource.ts b/web/src/hooks/useAuthenticatedPluginResource.ts new file mode 100644 index 000000000..8ec46df27 --- /dev/null +++ b/web/src/hooks/useAuthenticatedPluginResource.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +export function useAuthenticatedPluginIcon( + author: string, + name: string, + enabled = true, +): { url: string; error: boolean } { + const [url, setURL] = useState(''); + const [error, setError] = useState(false); + + useEffect(() => { + if (!enabled) { + setURL(''); + setError(false); + return; + } + let active = true; + let objectURL = ''; + setURL(''); + setError(false); + httpClient + .getAuthenticatedPluginIconURL(author, name) + .then((nextURL) => { + objectURL = nextURL; + if (active) setURL(nextURL); + else URL.revokeObjectURL(nextURL); + }) + .catch(() => { + if (active) setError(true); + }); + return () => { + active = false; + if (objectURL) URL.revokeObjectURL(objectURL); + }; + }, [author, enabled, name]); + + return { url, error }; +} + +export function useAuthenticatedPluginAsset( + author: string, + name: string, + filepath: string, +): { url: string; error: boolean } { + const [url, setURL] = useState(''); + const [error, setError] = useState(false); + + useEffect(() => { + let active = true; + let objectURL = ''; + setURL(''); + setError(false); + httpClient + .getAuthenticatedPluginAssetURL(author, name, filepath) + .then((nextURL) => { + objectURL = nextURL; + if (active) setURL(nextURL); + else URL.revokeObjectURL(nextURL); + }) + .catch(() => { + if (active) setError(true); + }); + return () => { + active = false; + if (objectURL) URL.revokeObjectURL(objectURL); + }; + }, [author, name, filepath]); + + return { url, error }; +} diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index e64f0da4c..6d7a137f6 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -175,6 +175,8 @@ const esES = { more: 'Más ({{count}})', less: 'Menos', noItems: 'Sin elementos', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: 'Página no encontrada', @@ -324,6 +326,11 @@ const esES = { fallbackList: 'Modelos de respaldo', addFallback: 'Añadir modelo de respaldo', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: 'Bots', @@ -1374,6 +1381,13 @@ const esES = { 'Establece una contraseña para iniciar sesión con correo y contraseña', spaceEmailMismatch: 'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: 'Panel de control', @@ -1632,6 +1646,8 @@ const esES = { api: 'API', storage: 'Almacenamiento', account: 'Cuenta', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1948,6 +1964,90 @@ const esES = { unsupportedFileType: 'Tipo de archivo no admitido. Solo se admiten archivos .zip y .lbpkg', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + 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', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default esES; diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 60cdf8742..b9e144538 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1636,6 +1636,11 @@ const jaJP = { operator: 'オペレーター', viewer: '閲覧者', }, + + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', }, monitoring: { title: 'ダッシュボード', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 205f1b0ee..c06ffede2 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -172,6 +172,8 @@ const ruRU = { less: 'Свернуть', noItems: 'Нет элементов', termsOfService: 'Условия обслуживания', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: 'Страница не найдена', @@ -323,6 +325,11 @@ const ruRU = { fallbackList: 'Резервные модели', addFallback: 'Добавить резервную модель', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: 'Боты', @@ -1349,6 +1356,13 @@ const ruRU = { setPasswordHint: 'Установите пароль для входа с email и паролем', spaceEmailMismatch: 'Email входа через Space не совпадает с email локальной учётной записи', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: 'Мониторинг', @@ -1606,6 +1620,8 @@ const ruRU = { api: 'API', storage: 'Хранилище', account: 'Аккаунт', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1914,6 +1930,90 @@ const ruRU = { unsupportedFileType: 'Неподдерживаемый тип файла. Поддерживаются только файлы .zip и .lbpkg', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + 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', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default ruRU; diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 10de6c999..5a2d3c0c5 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -169,6 +169,8 @@ const thTH = { more: 'เพิ่มเติม ({{count}})', less: 'น้อยลง', noItems: 'ไม่มีรายการ', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: 'ไม่พบหน้า', @@ -310,6 +312,11 @@ const thTH = { fallbackList: 'โมเดลสำรอง', addFallback: 'เพิ่มโมเดลสำรอง', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: 'บอท', @@ -1317,6 +1324,13 @@ const thTH = { bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี', setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน', spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: 'แดชบอร์ด', @@ -1573,6 +1587,8 @@ const thTH = { api: 'API', storage: 'พื้นที่จัดเก็บ', account: 'บัญชี', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1871,6 +1887,90 @@ const thTH = { createSkillHint: 'นำเข้าจากไดเรกทอรีในเครื่องหรือสร้างด้วยตนเอง', unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .zip และ .lbpkg', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + 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', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default thTH; diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 1ad88851a..0b7eb9e41 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -172,6 +172,8 @@ const viVN = { more: 'Thêm ({{count}})', less: 'Thu gọn', noItems: 'Không có mục nào', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: 'Không tìm thấy trang', @@ -319,6 +321,11 @@ const viVN = { fallbackList: 'Mô hình dự phòng', addFallback: 'Thêm mô hình dự phòng', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: 'Bot', @@ -1343,6 +1350,13 @@ const viVN = { setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu', spaceEmailMismatch: 'Email đăng nhập Space không khớp với email tài khoản cục bộ', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: 'Bảng điều khiển', @@ -1599,6 +1613,8 @@ const viVN = { api: 'API', storage: 'Lưu trữ', account: 'Tài khoản', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1905,6 +1921,90 @@ const viVN = { unsupportedFileType: 'Loại tệp không được hỗ trợ. Chỉ hỗ trợ tệp .zip và .lbpkg', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + 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', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default viVN; diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index b544929e8..a16a632af 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -160,6 +160,8 @@ const zhHant = { more: '更多 ({{count}})', less: '收起', noItems: '暫無內容', + + apiKeyStoredSecurely: 'Secret shown only when created', }, notFound: { title: '頁面不存在', @@ -300,6 +302,11 @@ const zhHant = { fallbackList: '備用模型', addFallback: '新增備用模型', }, + + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", }, bots: { title: '機器人', @@ -1270,6 +1277,13 @@ const zhHant = { bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起', setPasswordHint: '設定密碼後可使用電子郵件密碼登入', spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配', + + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, monitoring: { title: '儀表盤', @@ -1525,6 +1539,8 @@ const zhHant = { api: 'API', storage: '儲存', account: '帳戶', + + workspace: 'Workspace', }, }, storageAnalysis: { @@ -1812,6 +1828,90 @@ const zhHant = { saveFileSuccess: '檔案儲存成功', saveFileError: '檔案儲存失敗:', }, + + workspace: { + title: 'Workspace', + description: 'Manage members, roles, and invitation links', + selectTitle: 'Choose a Workspace', + selectDescription: 'Select where you want to continue in LangBot.', + selectionLoadFailed: + 'Your Workspaces could not be loaded. Please try again.', + switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', + ossSingletonDescription: + 'This self-hosted instance has one Workspace and can include multiple users.', + cloudManagedDescription: + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', + loadFailed: 'Failed to load Workspace information', + members: 'Members', + you: 'You', + inviteMember: 'Invite a member', + inviteDescription: + 'Create a one-time link to add another user to this Workspace.', + emailPlaceholder: 'member@example.com', + createInvitation: 'Create invitation', + 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', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', + copyInvitation: 'Copy invitation link', + invitationCopied: 'Invitation link copied', + pendingInvitations: 'Pending invitations', + expiresAt: 'Expires {{date}}', + revokeInvitation: 'Revoke invitation', + invitationRevoked: 'Invitation revoked', + invitationRevokeFailed: 'Failed to revoke invitation', + acceptInvitation: 'Accept invitation', + invitedToWorkspace: 'You were invited to {{workspace}}', + checkingInvitation: 'Checking this invitation...', + invitationMissing: 'This invitation link is missing required information.', + invitationExpired: 'This invitation has expired.', + invitationAlreadyRevoked: 'This invitation was revoked.', + invitationAlreadyUsed: 'This invitation was already used.', + invitationInvalid: 'This invitation is invalid or no longer available.', + invitationAccepted: 'Invitation accepted', + invitationAcceptFailed: 'Failed to accept invitation', + invitationEmailMismatch: + 'This invitation belongs to a different email address.', + existingAccountLoginRequired: + 'An account already exists for this email. Sign in to continue.', + acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', + switchAccount: 'Switch account', + registerAndAccept: 'Create account and accept', + alreadyHaveAccount: 'I already have an account', + confirmPassword: 'Confirm password', + passwordMinimum: 'Password must contain at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + backToLogin: 'Back to sign in', + memberUpdated: 'Member role updated', + memberUpdateFailed: 'Failed to update member role', + removeMember: 'Remove member', + removeMemberConfirm: 'Remove this member from the Workspace?', + memberRemoved: 'Member removed', + memberRemoveFailed: 'Failed to remove member', + transferOwnership: 'Transfer ownership', + types: { + personal: 'Personal', + team: 'Team', + }, + roles: { + owner: 'Owner', + admin: 'Admin', + developer: 'Developer', + operator: 'Operator', + viewer: 'Viewer', + }, + }, }; export default zhHant; diff --git a/web/tests/unit/support-admin-session.test.mjs b/web/tests/unit/support-admin-session.test.mjs new file mode 100644 index 000000000..3e5135a77 --- /dev/null +++ b/web/tests/unit/support-admin-session.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('support-admin launch stores a scoped principal instead of starting an Account session', () => { + const callback = read('src/app/auth/space/callback/page.tsx'); + assert.match(callback, /response\.principal_type === 'support_admin'/); + assert.match( + callback, + /beginSupportAdminSession\(response\.token, response\.workspace_uuid\)/, + ); +}); + +test('support-admin workspace bootstrap never calls Account bootstrap', () => { + const source = read('src/app/infra/http/index.ts'); + assert.match(source, /export function beginSupportAdminSession/); + assert.match(source, /export function isSupportAdminSession\(\): boolean/); + const supportBranch = source.indexOf('if (isSupportAdminSession())'); + const accountBootstrap = source.indexOf( + 'backendClient.getWorkspaceBootstrap()', + ); + assert.ok(supportBranch >= 0); + assert.ok(accountBootstrap > supportBranch); + assert.match( + source.slice(supportBranch, accountBootstrap), + /initializeWorkspaceInfo\([\s\S]*status: 'ready'/, + ); + assert.match( + source, + /localStorage\.setItem\('authPrincipalType', 'support_admin'\)/, + ); + const homeLayout = read('src/app/home/layout.tsx'); + assert.match(homeLayout, /!isSupportAdminSession\(\)/); +});