Merge pull request #2520 from langbot-app/feat/api-key-system-context

为 lbctl 增加 API Key 发现与管理能力
当前为 lbctl 提供的能力通过 system.py -> SYSTEM_CAPABILITY_OPERATIONS 维护,后续可通过统一的接口暴露能力,避免频繁维护常量。
This commit is contained in:
Tynwink
2026-09-09 14:47:00 +08:00
committed by GitHub
8 changed files with 723 additions and 24 deletions
+196
View File
@@ -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,
@@ -462,6 +464,12 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi
)
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={
@@ -478,6 +486,101 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi
'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',
'task.list',
'task.get',
'knowledge_base.list',
'knowledge_base.get',
'knowledge_base.create',
'knowledge_base.update',
'knowledge_base.delete',
'knowledge_base.file.list',
'knowledge_base.file.store',
'knowledge_base.file.delete',
'knowledge_base.retrieve',
'file.document.upload',
'plugin.install.github',
'plugin.install.marketplace',
'plugin.install.local',
'plugin.upgrade',
'plugin.get',
'plugin.list',
'plugin.config.get',
'plugin.config.update',
'plugin.logs',
'plugin.delete',
'provider.list',
'provider.get',
'provider.create',
'provider.update',
'provider.delete',
'provider.scan_models',
'model.llm.list',
'model.llm.get',
'model.llm.create',
'model.llm.update',
'model.llm.delete',
'model.llm.test',
'model.embedding.list',
'model.embedding.get',
'model.embedding.create',
'model.embedding.update',
'model.embedding.delete',
'model.embedding.test',
'model.rerank.list',
'model.rerank.get',
'model.rerank.create',
'model.rerank.update',
'model.rerank.delete',
'model.rerank.test',
'skill.list',
'skill.get',
'skill.create',
'skill.update',
'skill.delete',
'skill.files.list',
'skill.files.read',
'skill.files.write',
'skill.preview',
'skill.install.github',
'skill.install.upload',
'mcp_server.list',
'mcp_server.get',
'mcp_server.create',
'mcp_server.update',
'mcp_server.delete',
'mcp_server.resources',
'mcp_server.resource_templates',
'mcp_server.resource_read',
'mcp_server.logs',
'mcp_server.test',
]
)
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"]}'},
@@ -491,6 +594,17 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi
)
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),
@@ -502,6 +616,88 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi
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_api_key_can_query_tasks_with_public_contract_and_resource_permission(workspace_api):
application, client, _, owner_token = workspace_api
task_query = {}
task_lookup = {}
fake_task = SimpleNamespace(
to_public_dict=lambda: {'id': 7, 'status': 'running', 'error': None, 'result': None},
to_dict=lambda: {'id': 7, 'runtime': {'state': 'PENDING'}},
)
def get_tasks_dict(*args, **kwargs):
task_query.update(kwargs)
if kwargs.get('public'):
return {'tasks': []}
return {'tasks': [], 'id_index': 1}
def get_task_by_id(*args, **kwargs):
task_lookup.update(kwargs)
return fake_task if args and args[0] == 7 else None
application.task_mgr = SimpleNamespace(
get_tasks_dict=get_tasks_dict,
get_task_by_id=get_task_by_id,
)
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': 'Task reader', 'scopes': ['resource.view']},
)
assert create_response.status_code == 200
key = (await create_response.get_json())['data']['key']['key']
listing = await client.get('/api/v1/system/tasks', headers={'X-API-Key': key})
assert listing.status_code == 200
assert (await listing.get_json())['data'] == {'tasks': []}
assert task_query['instance_uuid'] == application.workspace_service.instance_uuid
assert task_query['workspace_uuid'] == workspace_uuid
assert task_query['placement_generation'] == 1
assert task_query['public'] is True
bearer_listing = await client.get('/api/v1/system/tasks', headers=_auth(owner_token, workspace_uuid))
assert bearer_listing.status_code == 200
assert (await bearer_listing.get_json())['data'] == {'tasks': [], 'id_index': 1}
public_task = await client.get('/api/v1/system/tasks/7', headers={'X-API-Key': key})
assert public_task.status_code == 200
assert (await public_task.get_json())['data'] == {
'id': 7,
'status': 'running',
'error': None,
'result': None,
}
assert task_lookup == {
'instance_uuid': application.workspace_service.instance_uuid,
'workspace_uuid': workspace_uuid,
'placement_generation': 1,
}
legacy_task = await client.get('/api/v1/system/tasks/7', headers=_auth(owner_token, workspace_uuid))
assert legacy_task.status_code == 200
assert (await legacy_task.get_json())['data'] == {'id': 7, 'runtime': {'state': 'PENDING'}}
missing = await client.get('/api/v1/system/tasks/not-an-id', headers={'X-API-Key': key})
assert missing.status_code == 404
no_permission_response = await client.post(
'/api/v1/apikeys',
headers=_auth(owner_token, workspace_uuid),
json={'name': 'Task denied', 'scopes': []},
)
assert no_permission_response.status_code == 200
no_permission_key = (await no_permission_response.get_json())['data']['key']['key']
denied = await client.get('/api/v1/system/tasks', headers={'X-API-Key': no_permission_key})
assert denied.status_code == 403
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(