From bc32eb3ca035ea1ec12ae94820f44b98e981ea74 Mon Sep 17 00:00:00 2001 From: Tynwink2000 Date: Mon, 7 Sep 2026 14:22:27 +0800 Subject: [PATCH] feat(api): add system context endpoint for lbctl --- docs/API_KEY_AUTH.md | 17 +++++ skills/skills/langbot-mcp-ops/SKILL.md | 2 + .../pkg/api/http/controller/groups/system.py | 11 ++++ tests/integration/api/test_workspaces.py | 64 +++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/docs/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md index 49d80b6f9..f825ea0a5 100644 --- a/docs/API_KEY_AUTH.md +++ b/docs/API_KEY_AUTH.md @@ -88,6 +88,23 @@ Each endpoint accepts **either**: 1. **User Token** (via `Authorization: Bearer `) - for web UI and authenticated users 2. **API Key** (via `X-API-Key` or `Authorization: Bearer `) - for external services +### Inspecting API Key Identity + +`GET /api/v1/system/context` validates an API key (user JWT not accepted) and returns its bound identity without requiring resource permissions: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "instance_uuid": "...", + "workspace_uuid": "...", + "api_key_id": "...", + "permissions": ["..."] + } +} +``` + ## Example: Model Management ### List All LLM Models diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 7480f2b1a..41236bb32 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -43,6 +43,8 @@ Two kinds of key are accepted: Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose scopes do not authorize a tool gets `403 Forbidden`. +To inspect key identity and permissions, call `GET /api/v1/system/context` with the API key. + ## Client configuration ```json diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 9ae0e0bf2..a8be6ba22 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -15,6 +15,17 @@ from .....workspace.invitation_delivery import InvitationDeliveryService @group.group_class('system', '/api/v1/system') class SystemRouterGroup(group.RouterGroup): async def initialize(self) -> None: + @self.route('/context', methods=['GET'], auth_type=group.AuthType.API_KEY) + async def _(request_context: RequestContext) -> str: + return self.success( + data={ + 'instance_uuid': request_context.instance_uuid, + 'workspace_uuid': request_context.workspace_uuid, + 'api_key_id': request_context.principal.api_key_uuid, + 'permissions': sorted(request_context.workspace.permissions), + } + ) + @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: # Read wizard_status and wizard_progress from metadata table diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 04e798ca4..511607f0d 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -440,6 +440,70 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac assert (await forbidden.get_json())['code'] == 'permission_denied' +async def test_api_key_context_returns_bound_identity_without_workspace_permission(workspace_api): + application, client, _, owner_token = workspace_api + current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token)) + workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid'] + + create_response = await client.post( + '/api/v1/apikeys', + headers=_auth(owner_token, workspace_uuid), + json={'name': 'Context probe', 'scopes': []}, + ) + assert create_response.status_code == 200 + created = (await create_response.get_json())['data']['key'] + + missing_auth = await client.get('/api/v1/system/context') + assert missing_auth.status_code == 401 + + invalid_auth = await client.get( + '/api/v1/system/context', + headers={'X-API-Key': 'lbk_invalid'}, + ) + assert invalid_auth.status_code == 401 + + response = await client.get( + '/api/v1/system/context', + headers={ + 'X-API-Key': created['key'], + 'X-Workspace-Id': 'caller-selected-workspace-must-be-ignored', + }, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data'] == { + 'instance_uuid': application.workspace_service.instance_uuid, + 'workspace_uuid': workspace_uuid, + 'api_key_id': created['uuid'], + 'permissions': [], + } + + bearer_response = await client.get( + '/api/v1/system/context', + headers={'Authorization': f'Bearer {created["key"]}'}, + ) + assert bearer_response.status_code == 200 + assert (await bearer_response.get_json())['data']['api_key_id'] == created['uuid'] + + jwt_response = await client.get( + '/api/v1/system/context', + headers={'Authorization': f'Bearer {owner_token}'}, + ) + assert jwt_response.status_code == 401 + + revoke_response = await client.delete( + f'/api/v1/apikeys/{created["id"]}', + headers=_auth(owner_token, workspace_uuid), + ) + assert revoke_response.status_code == 200 + + revoked_response = await client.get( + '/api/v1/system/context', + headers={'X-API-Key': created['key']}, + ) + assert revoked_response.status_code == 401 + + async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core( workspace_api, ):