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.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 )