mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-08 18:47:14 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fa5e2f755 | |||
| bc32eb3ca0 |
@@ -88,6 +88,23 @@ Each endpoint accepts **either**:
|
||||
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
|
||||
2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,9 +12,44 @@ from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
|
||||
from .....workspace.invitation_delivery import InvitationDeliveryService
|
||||
|
||||
|
||||
SYSTEM_CAPABILITY_OPERATIONS = (
|
||||
'bot.list',
|
||||
'bot.get',
|
||||
'bot.create',
|
||||
'bot.update',
|
||||
'bot.delete',
|
||||
'pipeline.list',
|
||||
'pipeline.get',
|
||||
'pipeline.create',
|
||||
'pipeline.update',
|
||||
'pipeline.delete',
|
||||
'pipeline.copy',
|
||||
)
|
||||
|
||||
|
||||
@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('/capabilities', methods=['GET'], auth_type=group.AuthType.API_KEY)
|
||||
async def _() -> str:
|
||||
return self.success(
|
||||
data={
|
||||
'schema_version': 1,
|
||||
'operations': {operation: {'supported': True} for operation in SYSTEM_CAPABILITY_OPERATIONS},
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
# Read wizard_status and wizard_progress from metadata table
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
@@ -22,6 +23,7 @@ from langbot.pkg.api.http.service.apikey import ApiKeyService
|
||||
from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
|
||||
from langbot.pkg.entity.persistence import apikey
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import (
|
||||
Workspace,
|
||||
@@ -440,6 +442,120 @@ 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
|
||||
|
||||
invalid_capabilities = await client.get(
|
||||
'/api/v1/system/capabilities',
|
||||
headers={'X-API-Key': 'lbk_invalid'},
|
||||
)
|
||||
assert invalid_capabilities.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': [],
|
||||
}
|
||||
|
||||
capabilities_response = await client.get(
|
||||
'/api/v1/system/capabilities',
|
||||
headers={
|
||||
'X-API-Key': created['key'],
|
||||
'X-Workspace-Id': 'caller-selected-workspace-must-be-ignored',
|
||||
},
|
||||
)
|
||||
assert capabilities_response.status_code == 200
|
||||
capabilities = (await capabilities_response.get_json())['data']
|
||||
assert capabilities['schema_version'] == 1
|
||||
assert sorted(capabilities['operations']) == sorted(
|
||||
[
|
||||
'bot.list',
|
||||
'bot.get',
|
||||
'bot.create',
|
||||
'bot.update',
|
||||
'bot.delete',
|
||||
'pipeline.list',
|
||||
'pipeline.get',
|
||||
'pipeline.create',
|
||||
'pipeline.update',
|
||||
'pipeline.delete',
|
||||
'pipeline.copy',
|
||||
]
|
||||
)
|
||||
assert all(item == {'supported': True} for item in capabilities['operations'].values())
|
||||
assert created['key'] not in await capabilities_response.get_data(as_text=True)
|
||||
|
||||
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
|
||||
|
||||
await application.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(apikey.ApiKey)
|
||||
.where(apikey.ApiKey.uuid == created['uuid'])
|
||||
.values(expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(seconds=1))
|
||||
)
|
||||
expired_capabilities = await client.get(
|
||||
'/api/v1/system/capabilities',
|
||||
headers={'X-API-Key': created['key']},
|
||||
)
|
||||
assert expired_capabilities.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
|
||||
revoked_capabilities = await client.get(
|
||||
'/api/v1/system/capabilities',
|
||||
headers={'X-API-Key': created['key']},
|
||||
)
|
||||
assert revoked_capabilities.status_code == 401
|
||||
|
||||
|
||||
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
|
||||
workspace_api,
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user