From 23db592c4a6efb913d5bbfa4bdb730b3de1e00ca Mon Sep 17 00:00:00 2001 From: wizardchen Date: Tue, 21 Jul 2026 15:05:57 +0800 Subject: [PATCH] feat(api-keys): add platform-scoped API keys for control-plane automation Introduce platform API keys with capability-based access to tenant management, system settings, runtime queues, and audit logs, plus admin UI for key lifecycle and system audit log viewing. --- cmd/server/main.go | 2 +- docs/QA.md | 4 + docs/api/tenant.md | 33 +- docs/docs.go | 128 ++- docs/swagger.json | 128 ++- docs/swagger.yaml | 91 +- frontend/src/api/system/index.ts | 24 + frontend/src/api/tenant/index.ts | 8 + frontend/src/config/apiKeyCapabilities.ts | 100 ++ frontend/src/i18n/locales/en-US.ts | 17 + frontend/src/i18n/locales/ko-KR.ts | 12 + frontend/src/i18n/locales/ru-RU.ts | 12 + frontend/src/i18n/locales/zh-CN.ts | 18 + .../integrations/ApiIntegrationSettings.vue | 99 +- frontend/src/views/settings/Settings.vue | 18 +- frontend/src/views/system/PlatformAPIKeys.vue | 692 +++++++++++++ frontend/src/views/system/RuntimeQueues.vue | 67 +- frontend/src/views/system/SystemAuditLog.vue | 755 +++++++++++++++ frontend/src/views/system/SystemSettings.vue | 906 +----------------- .../application/repository/tenant_api_key.go | 24 + .../application/service/tenant_api_key.go | 27 +- .../service/tenant_api_key_test.go | 60 +- internal/handler/system.go | 145 ++- internal/handler/tenant.go | 36 +- internal/middleware/access.go | 4 + internal/middleware/api_key_gate.go | 10 + internal/middleware/api_key_gate_test.go | 29 + internal/middleware/auth.go | 143 ++- .../middleware/auth_platform_api_key_test.go | 95 ++ internal/middleware/rbac.go | 11 +- .../rbac_api_key_shortcircuit_test.go | 11 + internal/router/rbac.go | 8 + internal/router/router.go | 66 +- .../router_api_key_capabilities_test.go | 66 ++ internal/types/audit_log.go | 2 + internal/types/interfaces/tenant.go | 5 + internal/types/principal.go | 1 + internal/types/tenant_api_key.go | 76 +- .../sqlite/000071_platform_api_keys.down.sql | 40 + .../sqlite/000071_platform_api_keys.up.sql | 44 + .../000071_platform_api_keys.down.sql | 12 + .../versioned/000071_platform_api_keys.up.sql | 21 + 42 files changed, 2967 insertions(+), 1083 deletions(-) create mode 100644 frontend/src/config/apiKeyCapabilities.ts create mode 100644 frontend/src/views/system/PlatformAPIKeys.vue create mode 100644 frontend/src/views/system/SystemAuditLog.vue create mode 100644 internal/middleware/auth_platform_api_key_test.go create mode 100644 migrations/sqlite/000071_platform_api_keys.down.sql create mode 100644 migrations/sqlite/000071_platform_api_keys.up.sql create mode 100644 migrations/versioned/000071_platform_api_keys.down.sql create mode 100644 migrations/versioned/000071_platform_api_keys.up.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 3f027c06..9f008d68 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -19,7 +19,7 @@ // @securityDefinitions.apikey ApiKeyAuth // @in header // @name X-API-Key -// @description 空间身份认证:输入 sk- 开头的 API Key +// @description API Key 认证:空间 Key 固定访问所属空间;平台 Key 调用空间接口时需同时传 X-Tenant-ID package main import ( diff --git a/docs/QA.md b/docs/QA.md index 5f1c18c8..fcd98dce 100644 --- a/docs/QA.md +++ b/docs/QA.md @@ -326,6 +326,10 @@ Wiki 模式允许 Agent 根据原始文档自动生成并维护一套结构化 - 路由级守卫会拒绝越权访问;管理类接口对 API Key Principal 默认拒绝,请为集成使用具备对应能力的 Key,而非全权 Key。 - MCP OAuth 与嵌入会话按 Principal 隔离,不同集成之间互不串号。 +### 如何用一个 API Key 自动化管理多个空间? + +SystemAdmin 可在 **系统管理 → 平台 API Key** 创建 `scope_type=platform` 的 Key。平台 Key 不绑定单一空间:调用普通空间 API 时必须携带 `X-Tenant-ID`,并继续受原有 capability 和知识库范围守卫约束;调用开放的系统控制面接口则需要对应的 `system_*` capability。平台 Key 不支持 `full_access`,也不能创建、轮换或吊销其他平台 Key。 + ## 29. 一个空间如何绑定多个对象存储实例? 0.7.0 支持**多实例存储后端**(迁移 `000068_storage_backends`)。一个空间可注册多个存储实例(`local` / `minio` / `cos` / `tos` / `s3` / `oss` / `ks3` / `obs`),不同知识库绑定到不同实例,空间维度还有一个默认实例: diff --git a/docs/api/tenant.md b/docs/api/tenant.md index d0145045..06864e1d 100644 --- a/docs/api/tenant.md +++ b/docs/api/tenant.md @@ -351,7 +351,7 @@ curl --location --request DELETE 'http://localhost:8080/api/v1/tenants/10000' \ } ``` -## 空间 API Key 管理(`tenant_api_keys`) +## API Key 管理(`tenant_api_keys`) 自 scoped API Key 改造后,密钥以独立记录存储,支持: @@ -360,7 +360,36 @@ curl --location --request DELETE 'http://localhost:8080/api/v1/tenants/10000' \ - **吊销**:`DELETE /tenants/:id/api-keys/:key_id` - **过期**:创建时可选 `expires_at_unix` -认证上下文中的空间角色与 Key 的 `role` 一致(`viewer` / `contributor` / `admin`)。路由级 API Key 鉴权与 KB 访问守卫在 `X-API-Key` 认证后强制执行。 +空间 Key 固定绑定创建时的空间。路由级 capability 鉴权与 KB 访问守卫会在 `X-API-Key` 认证后继续强制执行。 + +### 平台 API Key + +系统管理员可在“系统管理 → 平台 API Key”创建不绑定单一空间的 Key。平台 Key 默认可以选择任意存在的空间,但每项操作仍必须具备对应 capability;平台 Key 不支持 `full_access`。 + +- 管理接口:`GET/POST /system/admin/api-keys`、`DELETE /system/admin/api-keys/:key_id`,仅人类 SystemAdmin 会话可调用,平台 Key 不能创建或吊销其他平台 Key。 +- 调用普通空间 API 时必须同时传 `X-Tenant-ID: <空间 ID>`;服务端解析目标空间后继续复用原有空间 Context、路由 capability 和知识库范围检查。 +- 调用明确开放的 `/system/admin/*` 控制面接口时不需要 `X-Tenant-ID`,需要 `system_*` capability。 +- 平台 Key 明文仅在创建响应的 `data.token` 返回一次;列表仅返回脱敏值。 + +```bash +curl 'http://localhost:8080/api/v1/knowledge-bases' \ + -H 'X-API-Key: ' \ + -H 'X-Tenant-ID: 10000' +``` + +平台 capability: + +| capability | 权限 | +| --- | --- | +| `system_tenants_read` | 列出、搜索、查看全部空间 | +| `system_tenants_manage` | 创建、更新、删除空间以及应用全局空间配置 | +| `system_settings_read` | 读取系统设置 | +| `system_settings_manage` | 更新、重置系统设置 | +| `system_runtime_read` | 查看运行时队列和任务 | +| `system_runtime_manage` | 重试、立即执行、取消、删除运行时任务 | +| `system_audit_read` | 读取平台审计日志 | + +平台 Key 也可以携带现有空间 capability,例如 `retrieve`、`ingest`、`manage_kbs`;这些能力作用于请求中 `X-Tenant-ID` 指定的空间。 ## API Key Principal:隔离边界与安全说明 diff --git a/docs/docs.go b/docs/docs.go index 676a8fd4..814284b8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -10098,7 +10098,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "来源过滤:web / feishu / wechat / slack / ...", + "description": "来源过滤:web / embed / api / feishu / wechat / slack / ...(api、embed、IM 渠道需 Admin+)", "name": "source", "in": "query" }, @@ -11545,6 +11545,105 @@ const docTemplate = `{ } } }, + "/system/admin/api-keys": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Lists non-workspace API keys. Full tokens are always masked. Human SystemAdmin only.", + "produces": [ + "application/json" + ], + "tags": [ + "System Admin" + ], + "summary": "List platform API keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a capability-scoped key that may target any workspace through X-Tenant-ID. The plaintext token is returned once. Human SystemAdmin only.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Admin" + ], + "summary": "Create a platform API key", + "parameters": [ + { + "description": "Platform API key", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler.platformAPIKeyCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/system/admin/api-keys/{key_id}": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Immediately revokes a platform API key. Human SystemAdmin only.", + "produces": [ + "application/json" + ], + "tags": [ + "System Admin" + ], + "summary": "Revoke a platform API key", + "parameters": [ + { + "type": "integer", + "description": "API key ID", + "name": "key_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/system/admin/audit-log": { "get": { "security": [ @@ -14745,6 +14844,8 @@ const docTemplate = `{ "system.admin_promoted", "system.admin_revoked", "system.user_password_reset", + "system.api_key_created", + "system.api_key_revoked", "system.queue_task_retried", "system.queue_task_deleted", "system.queue_task_run_now", @@ -14771,6 +14872,8 @@ const docTemplate = `{ "AuditActionSystemAdminPromoted", "AuditActionSystemAdminRevoked", "AuditActionSystemUserPasswordReset", + "AuditActionSystemAPIKeyCreated", + "AuditActionSystemAPIKeyRevoked", "AuditActionSystemQueueTaskRetried", "AuditActionSystemQueueTaskDeleted", "AuditActionSystemQueueTaskRunNow", @@ -18297,6 +18400,10 @@ const docTemplate = `{ "description": "ID", "type": "string" }, + "im_platform": { + "description": "IMPlatform is the originating IM platform (e.g. \"feishu\", \"wecom\") when\nthis session is bound to an IM channel. It is not stored on the sessions\ntable (it lives in im_channel_sessions) and is populated on read so the\nWeb console can classify a session's origin folder without a list query.", + "type": "string" + }, "is_pinned": { "description": "IsPinned indicates whether the session is pinned in the list.", "type": "boolean" @@ -21473,6 +21580,23 @@ const docTemplate = `{ } } }, + "internal_handler.platformAPIKeyCreateRequest": { + "type": "object", + "properties": { + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "expires_at_unix": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, "internal_handler.registerByInviteRequest": { "type": "object", "required": [ @@ -21835,7 +21959,7 @@ const docTemplate = `{ }, "securityDefinitions": { "ApiKeyAuth": { - "description": "空间身份认证:输入 sk- 开头的 API Key", + "description": "API Key 认证:空间 Key 固定访问所属空间;平台 Key 调用空间接口时需同时传 X-Tenant-ID", "type": "apiKey", "name": "X-API-Key", "in": "header" diff --git a/docs/swagger.json b/docs/swagger.json index 2f8fec24..adb651a6 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -10091,7 +10091,7 @@ }, { "type": "string", - "description": "来源过滤:web / feishu / wechat / slack / ...", + "description": "来源过滤:web / embed / api / feishu / wechat / slack / ...(api、embed、IM 渠道需 Admin+)", "name": "source", "in": "query" }, @@ -11538,6 +11538,105 @@ } } }, + "/system/admin/api-keys": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Lists non-workspace API keys. Full tokens are always masked. Human SystemAdmin only.", + "produces": [ + "application/json" + ], + "tags": [ + "System Admin" + ], + "summary": "List platform API keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a capability-scoped key that may target any workspace through X-Tenant-ID. The plaintext token is returned once. Human SystemAdmin only.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "System Admin" + ], + "summary": "Create a platform API key", + "parameters": [ + { + "description": "Platform API key", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler.platformAPIKeyCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/system/admin/api-keys/{key_id}": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Immediately revokes a platform API key. Human SystemAdmin only.", + "produces": [ + "application/json" + ], + "tags": [ + "System Admin" + ], + "summary": "Revoke a platform API key", + "parameters": [ + { + "type": "integer", + "description": "API key ID", + "name": "key_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/system/admin/audit-log": { "get": { "security": [ @@ -14738,6 +14837,8 @@ "system.admin_promoted", "system.admin_revoked", "system.user_password_reset", + "system.api_key_created", + "system.api_key_revoked", "system.queue_task_retried", "system.queue_task_deleted", "system.queue_task_run_now", @@ -14764,6 +14865,8 @@ "AuditActionSystemAdminPromoted", "AuditActionSystemAdminRevoked", "AuditActionSystemUserPasswordReset", + "AuditActionSystemAPIKeyCreated", + "AuditActionSystemAPIKeyRevoked", "AuditActionSystemQueueTaskRetried", "AuditActionSystemQueueTaskDeleted", "AuditActionSystemQueueTaskRunNow", @@ -18290,6 +18393,10 @@ "description": "ID", "type": "string" }, + "im_platform": { + "description": "IMPlatform is the originating IM platform (e.g. \"feishu\", \"wecom\") when\nthis session is bound to an IM channel. It is not stored on the sessions\ntable (it lives in im_channel_sessions) and is populated on read so the\nWeb console can classify a session's origin folder without a list query.", + "type": "string" + }, "is_pinned": { "description": "IsPinned indicates whether the session is pinned in the list.", "type": "boolean" @@ -21466,6 +21573,23 @@ } } }, + "internal_handler.platformAPIKeyCreateRequest": { + "type": "object", + "properties": { + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "expires_at_unix": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, "internal_handler.registerByInviteRequest": { "type": "object", "required": [ @@ -21828,7 +21952,7 @@ }, "securityDefinitions": { "ApiKeyAuth": { - "description": "空间身份认证:输入 sk- 开头的 API Key", + "description": "API Key 认证:空间 Key 固定访问所属空间;平台 Key 调用空间接口时需同时传 X-Tenant-ID", "type": "apiKey", "name": "X-API-Key", "in": "header" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 093cb783..df32883e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -294,6 +294,8 @@ definitions: - system.admin_promoted - system.admin_revoked - system.user_password_reset + - system.api_key_created + - system.api_key_revoked - system.queue_task_retried - system.queue_task_deleted - system.queue_task_run_now @@ -320,6 +322,8 @@ definitions: - AuditActionSystemAdminPromoted - AuditActionSystemAdminRevoked - AuditActionSystemUserPasswordReset + - AuditActionSystemAPIKeyCreated + - AuditActionSystemAPIKeyRevoked - AuditActionSystemQueueTaskRetried - AuditActionSystemQueueTaskDeleted - AuditActionSystemQueueTaskRunNow @@ -3060,6 +3064,13 @@ definitions: id: description: ID type: string + im_platform: + description: |- + IMPlatform is the originating IM platform (e.g. "feishu", "wecom") when + this session is bound to an IM channel. It is not stored on the sessions + table (it lives in im_channel_sessions) and is populated on read so the + Web console can classify a session's origin folder without a list query. + type: string is_pinned: description: IsPinned indicates whether the session is pinned in the list. type: boolean @@ -5374,6 +5385,17 @@ definitions: tenant_name: type: string type: object + internal_handler.platformAPIKeyCreateRequest: + properties: + capabilities: + items: + type: string + type: array + expires_at_unix: + type: integer + name: + type: string + type: object internal_handler.registerByInviteRequest: properties: email: @@ -12044,7 +12066,8 @@ paths: in: query name: keyword type: string - - description: 来源过滤:web / feishu / wechat / slack / ... + - description: 来源过滤:web / embed / api / feishu / wechat / slack / ...(api、embed、IM + 渠道需 Admin+) in: query name: source type: string @@ -12971,6 +12994,70 @@ paths: summary: List allowed storage provider types tags: - StorageBackend + /system/admin/api-keys: + get: + description: Lists non-workspace API keys. Full tokens are always masked. Human + SystemAdmin only. + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - Bearer: [] + summary: List platform API keys + tags: + - System Admin + post: + consumes: + - application/json + description: Creates a capability-scoped key that may target any workspace through + X-Tenant-ID. The plaintext token is returned once. Human SystemAdmin only. + parameters: + - description: Platform API key + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handler.platformAPIKeyCreateRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + security: + - Bearer: [] + summary: Create a platform API key + tags: + - System Admin + /system/admin/api-keys/{key_id}: + delete: + description: Immediately revokes a platform API key. Human SystemAdmin only. + parameters: + - description: API key ID + in: path + name: key_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - Bearer: [] + summary: Revoke a platform API key + tags: + - System Admin /system/admin/audit-log: get: description: 返回 system-scope(tenant_id=0)的审计事件,覆盖 system.setting_changed / system.admin_promoted @@ -14827,7 +14914,7 @@ paths: - WeKnoraCloud securityDefinitions: ApiKeyAuth: - description: 空间身份认证:输入 sk- 开头的 API Key + description: API Key 认证:空间 Key 固定访问所属空间;平台 Key 调用空间接口时需同时传 X-Tenant-ID in: header name: X-API-Key type: apiKey diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts index 3f26b722..df0008ad 100644 --- a/frontend/src/api/system/index.ts +++ b/frontend/src/api/system/index.ts @@ -1,4 +1,28 @@ import { get, post, put, del } from '@/utils/request' +import type { CreatedTenantAPIKey, TenantAPIKey, TenantAPIKeyCapability } from '@/api/tenant' + +export interface CreatePlatformAPIKeyPayload { + name: string + capabilities: TenantAPIKeyCapability[] + expires_at_unix?: number +} + +export async function listPlatformAPIKeys(): Promise<{ success: boolean; data?: TenantAPIKey[] }> { + return await get('/api/v1/system/admin/api-keys') as unknown as { success: boolean; data?: TenantAPIKey[] } +} + +export async function createPlatformAPIKey( + payload: CreatePlatformAPIKeyPayload, +): Promise<{ success: boolean; data?: CreatedTenantAPIKey }> { + return await post('/api/v1/system/admin/api-keys', payload) as unknown as { + success: boolean + data?: CreatedTenantAPIKey + } +} + +export async function deletePlatformAPIKey(keyId: number): Promise<{ success: boolean }> { + return await del(`/api/v1/system/admin/api-keys/${keyId}`) as unknown as { success: boolean } +} export interface SystemInfo { version: string diff --git a/frontend/src/api/tenant/index.ts b/frontend/src/api/tenant/index.ts index 6aca08a5..f638b662 100644 --- a/frontend/src/api/tenant/index.ts +++ b/frontend/src/api/tenant/index.ts @@ -85,9 +85,17 @@ export type TenantAPIKeyCapability = | 'manage_members' | 'manage_spaces' | 'manage_tenant_settings' + | 'system_tenants_read' + | 'system_tenants_manage' + | 'system_settings_read' + | 'system_settings_manage' + | 'system_runtime_read' + | 'system_runtime_manage' + | 'system_audit_read' export interface TenantAPIKey { id: number + scope_type?: 'tenant' | 'platform' name: string api_key: string full_access: boolean diff --git a/frontend/src/config/apiKeyCapabilities.ts b/frontend/src/config/apiKeyCapabilities.ts new file mode 100644 index 00000000..aba4842d --- /dev/null +++ b/frontend/src/config/apiKeyCapabilities.ts @@ -0,0 +1,100 @@ +import type { TenantAPIKeyCapability } from '@/api/tenant' + +export type ApiKeyCapabilityOption = { + value: TenantAPIKeyCapability + labelKey: string + hintKey: string +} + +export type ApiKeyCapabilityGroup = { + key: string + labelKey: string + capabilities: ApiKeyCapabilityOption[] +} + +export const TENANT_API_KEY_CAPABILITIES: TenantAPIKeyCapability[] = [ + 'retrieve', 'chat', 'read_agents', 'ingest', 'manage_kbs', + 'message_history', 'manage_agents', 'manage_mcp_services', + 'manage_datasources', 'manage_models', 'manage_vector_stores', + 'manage_storage_backends', 'manage_web_search', 'manage_channels', + 'run_evaluations', 'manage_members', 'manage_spaces', + 'manage_tenant_settings', +] + +export const SYSTEM_API_KEY_CAPABILITIES: TenantAPIKeyCapability[] = [ + 'system_tenants_read', 'system_tenants_manage', + 'system_settings_read', 'system_settings_manage', + 'system_runtime_read', 'system_runtime_manage', 'system_audit_read', +] + +export const DEFAULT_TENANT_API_KEY_CAPABILITIES = new Set([ + 'retrieve', 'chat', 'read_agents', +]) + +export const KB_SCOPED_API_KEY_CAPABILITIES = new Set([ + 'retrieve', 'chat', 'ingest', 'manage_kbs', 'manage_agents', 'manage_datasources', +]) + +export const TENANT_API_KEY_CAPABILITY_GROUPS: ApiKeyCapabilityGroup[] = [ + { + key: 'knowledge', + labelKey: 'integrations.api.apiKeyCapabilityGroupKnowledge', + capabilities: [ + { value: 'retrieve', labelKey: 'integrations.api.capabilityRetrieve', hintKey: 'integrations.api.capabilityRetrieveHint' }, + { value: 'chat', labelKey: 'integrations.api.capabilityChat', hintKey: 'integrations.api.capabilityChatHint' }, + { value: 'ingest', labelKey: 'integrations.api.capabilityIngest', hintKey: 'integrations.api.capabilityIngestHint' }, + { value: 'manage_kbs', labelKey: 'integrations.api.capabilityManageKbs', hintKey: 'integrations.api.capabilityManageKbsHint' }, + { value: 'message_history', labelKey: 'integrations.api.capabilityMessageHistory', hintKey: 'integrations.api.capabilityMessageHistoryHint' }, + ], + }, + { + key: 'automation', + labelKey: 'integrations.api.apiKeyCapabilityGroupAutomation', + capabilities: [ + { value: 'read_agents', labelKey: 'integrations.api.capabilityReadAgents', hintKey: 'integrations.api.capabilityReadAgentsHint' }, + { value: 'manage_agents', labelKey: 'integrations.api.capabilityManageAgents', hintKey: 'integrations.api.capabilityManageAgentsHint' }, + { value: 'manage_mcp_services', labelKey: 'integrations.api.capabilityManageMcpServices', hintKey: 'integrations.api.capabilityManageMcpServicesHint' }, + { value: 'manage_datasources', labelKey: 'integrations.api.capabilityManageDatasources', hintKey: 'integrations.api.capabilityManageDatasourcesHint' }, + ], + }, + { + key: 'collaboration', + labelKey: 'integrations.api.apiKeyCapabilityGroupCollaboration', + capabilities: [ + { value: 'manage_members', labelKey: 'integrations.api.capabilityManageMembers', hintKey: 'integrations.api.capabilityManageMembersHint' }, + { value: 'manage_spaces', labelKey: 'integrations.api.capabilityManageSpaces', hintKey: 'integrations.api.capabilityManageSpacesHint' }, + ], + }, + { + key: 'tenant', + labelKey: 'integrations.api.apiKeyCapabilityGroupTenant', + capabilities: [ + { value: 'manage_models', labelKey: 'integrations.api.capabilityManageModels', hintKey: 'integrations.api.capabilityManageModelsHint' }, + { value: 'manage_vector_stores', labelKey: 'integrations.api.capabilityManageVectorStores', hintKey: 'integrations.api.capabilityManageVectorStoresHint' }, + { value: 'manage_storage_backends', labelKey: 'integrations.api.capabilityManageStorageBackends', hintKey: 'integrations.api.capabilityManageStorageBackendsHint' }, + { value: 'manage_web_search', labelKey: 'integrations.api.capabilityManageWebSearch', hintKey: 'integrations.api.capabilityManageWebSearchHint' }, + { value: 'manage_channels', labelKey: 'integrations.api.capabilityManageChannels', hintKey: 'integrations.api.capabilityManageChannelsHint' }, + { value: 'run_evaluations', labelKey: 'integrations.api.capabilityRunEvaluations', hintKey: 'integrations.api.capabilityRunEvaluationsHint' }, + { value: 'manage_tenant_settings', labelKey: 'integrations.api.capabilityManageTenantSettings', hintKey: 'integrations.api.capabilityManageTenantSettingsHint' }, + ], + }, +] + +export const SYSTEM_API_KEY_CAPABILITY_GROUP: ApiKeyCapabilityGroup = { + key: 'system', + labelKey: 'platformApiKeys.systemCapabilityGroup', + capabilities: [ + { value: 'system_tenants_read', labelKey: 'platformApiKeys.capabilities.tenantsRead', hintKey: 'platformApiKeys.capabilityHints.tenantsRead' }, + { value: 'system_tenants_manage', labelKey: 'platformApiKeys.capabilities.tenantsManage', hintKey: 'platformApiKeys.capabilityHints.tenantsManage' }, + { value: 'system_settings_read', labelKey: 'platformApiKeys.capabilities.settingsRead', hintKey: 'platformApiKeys.capabilityHints.settingsRead' }, + { value: 'system_settings_manage', labelKey: 'platformApiKeys.capabilities.settingsManage', hintKey: 'platformApiKeys.capabilityHints.settingsManage' }, + { value: 'system_runtime_read', labelKey: 'platformApiKeys.capabilities.runtimeRead', hintKey: 'platformApiKeys.capabilityHints.runtimeRead' }, + { value: 'system_runtime_manage', labelKey: 'platformApiKeys.capabilities.runtimeManage', hintKey: 'platformApiKeys.capabilityHints.runtimeManage' }, + { value: 'system_audit_read', labelKey: 'platformApiKeys.capabilities.auditRead', hintKey: 'platformApiKeys.capabilityHints.auditRead' }, + ], +} + +export const PLATFORM_API_KEY_CAPABILITY_GROUPS: ApiKeyCapabilityGroup[] = [ + SYSTEM_API_KEY_CAPABILITY_GROUP, + ...TENANT_API_KEY_CAPABILITY_GROUPS, +] diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 9e451bbc..5a808ec8 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -4017,6 +4017,8 @@ export default { action: { 'system.setting_changed': 'System setting changed', 'system.admin_promoted': 'System admin granted', + 'system.api_key_created': 'Platform API key created', + 'system.api_key_revoked': 'Platform API key revoked', 'system.admin_revoked': 'System admin revoked', 'system.user_password_reset': 'User password reset', 'system.queue_task_retried': 'Failed task run again', @@ -6893,4 +6895,19 @@ export default { generic: 'Something went wrong. Please try again.', }, }, + platformApiKeys: { + title: 'Platform API Keys', + description: 'Create platform credentials for cross-workspace automation. Use X-Tenant-ID for workspace APIs.', + securityNotice: 'Platform API keys can target any workspace. Grant only required capabilities; plaintext is shown once.', + create: 'Create platform API key', createDescription: 'Platform keys are not bound to a workspace; capabilities still limit every operation.', + loading: 'Loading…', empty: 'No platform API keys', name: 'Name', namePlaceholder: 'For example: central operations automation', + key: 'Key', capability: 'Capabilities', capabilityMore: '+{count}', viewAllCapabilities: 'View all capabilities', capabilityHint: 'Workspace capabilities apply to the X-Tenant-ID target; system capabilities apply to control-plane APIs.', + lastUsed: 'Last used', createdAt: 'Created', actions: 'Actions', never: 'Never', systemCapabilityGroup: 'Platform control plane', + capabilities: { tenantsRead: 'Read workspaces', tenantsManage: 'Manage workspaces', settingsRead: 'Read system settings', settingsManage: 'Manage system settings', runtimeRead: 'Read runtime', runtimeManage: 'Manage runtime', auditRead: 'Read system audit' }, + capabilityHints: { tenantsRead: 'List, search, and inspect every workspace.', tenantsManage: 'Create, update, delete workspaces and apply global workspace settings.', settingsRead: 'Read platform runtime settings.', settingsManage: 'Update and reset platform runtime settings.', runtimeRead: 'Inspect task queues and task details.', runtimeManage: 'Retry, run, cancel, or delete runtime tasks.', auditRead: 'Read platform audit events.' }, + createdTitle: 'Platform API key created', createdDescription: 'Copy and store this key now. The full value will not be shown again.', + copy: 'Copy key', copySuccess: 'Key copied', + deleteConfirm: 'Delete “{name}”? Automation using this key will stop immediately.', deleteSuccess: 'Platform API key deleted', deleteFailed: 'Failed to delete platform API key', + nameRequired: 'Enter a name', capabilityRequired: 'Select at least one capability', loadFailed: 'Failed to load platform API keys', createFailed: 'Failed to create platform API key', + }, } diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index d1b0b25c..3a8bbf75 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -3014,6 +3014,8 @@ export default { action: { "system.setting_changed": "시스템 설정 변경", "system.admin_promoted": "시스템 관리자 부여", + "system.api_key_created": "플랫폼 API 키 생성", + "system.api_key_revoked": "플랫폼 API 키 폐기", "system.admin_revoked": "시스템 관리자 회수", "system.user_password_reset": "사용자 비밀번호 재설정", "system.queue_task_retried": "실패 작업 다시 실행", @@ -6868,4 +6870,14 @@ export default { generic: "작업에 실패했습니다. 다시 시도해 주세요.", }, }, + platformApiKeys: { + title: "플랫폼 API 키", description: "워크스페이스 간 자동화를 위한 플랫폼 자격 증명입니다. 워크스페이스 API에는 X-Tenant-ID를 사용하세요.", + securityNotice: "플랫폼 API 키는 모든 워크스페이스를 선택할 수 있습니다. 필요한 권한만 부여하고, 한 번 표시되는 키를 안전하게 보관하세요.", + create: "플랫폼 API 키 만들기", createDescription: "플랫폼 키는 워크스페이스에 고정되지 않지만 모든 작업은 권한으로 제한됩니다.", + loading: "불러오는 중…", empty: "플랫폼 API 키가 없습니다", name: "이름", namePlaceholder: "예: 중앙 운영 자동화", key: "키", capability: "권한", capabilityMore: "+{count}", viewAllCapabilities: "모든 권한 보기", capabilityHint: "워크스페이스 권한은 X-Tenant-ID 대상에, 시스템 권한은 플랫폼 제어 API에 적용됩니다.", lastUsed: "마지막 사용", createdAt: "생성 시간", actions: "작업", never: "사용 안 함", systemCapabilityGroup: "플랫폼 제어 영역", + capabilities: { tenantsRead: "워크스페이스 조회", tenantsManage: "워크스페이스 관리", settingsRead: "시스템 설정 조회", settingsManage: "시스템 설정 관리", runtimeRead: "런타임 조회", runtimeManage: "런타임 관리", auditRead: "시스템 감사 조회" }, + capabilityHints: { tenantsRead: "모든 워크스페이스를 조회하고 검색합니다.", tenantsManage: "워크스페이스를 생성, 수정, 삭제합니다.", settingsRead: "플랫폼 설정을 조회합니다.", settingsManage: "플랫폼 설정을 변경합니다.", runtimeRead: "작업 큐와 상세 정보를 조회합니다.", runtimeManage: "작업을 재시도, 실행, 취소 또는 삭제합니다.", auditRead: "플랫폼 감사 로그를 조회합니다." }, + createdTitle: "플랫폼 API 키가 생성되었습니다", createdDescription: "지금 복사하여 안전하게 보관하세요. 전체 키는 다시 표시되지 않습니다.", copy: "키 복사", copySuccess: "키가 복사되었습니다", + deleteConfirm: "“{name}”을(를) 삭제할까요? 이 키를 사용하는 자동화가 즉시 중단됩니다.", deleteSuccess: "플랫폼 API 키가 삭제되었습니다", deleteFailed: "플랫폼 API 키를 삭제하지 못했습니다", nameRequired: "이름을 입력하세요", capabilityRequired: "권한을 하나 이상 선택하세요", loadFailed: "플랫폼 API 키를 불러오지 못했습니다", createFailed: "플랫폼 API 키를 만들지 못했습니다", + }, }; diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 8a3c5558..37d36c48 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -2702,6 +2702,8 @@ export default { action: { 'system.setting_changed': 'Изменена системная настройка', 'system.admin_promoted': 'Выдан системный администратор', + 'system.api_key_created': 'Создан платформенный API-ключ', + 'system.api_key_revoked': 'Отозван платформенный API-ключ', 'system.admin_revoked': 'Отозван системный администратор', 'system.user_password_reset': 'Сброшен пароль пользователя', 'system.queue_task_retried': 'Повторно запущена сбойная задача', @@ -6610,4 +6612,14 @@ export default { }, }, }, + platformApiKeys: { + title: 'Платформенные API-ключи', description: 'Учетные данные для автоматизации между рабочими пространствами. Для API пространства используйте X-Tenant-ID.', + securityNotice: 'Платформенный ключ может выбрать любое пространство. Выдавайте только необходимые права; секрет показывается один раз.', + create: 'Создать платформенный API-ключ', createDescription: 'Ключ не привязан к пространству, но каждая операция ограничена правами.', + loading: 'Загрузка…', empty: 'Платформенных ключей нет', name: 'Название', namePlaceholder: 'Например: центральная автоматизация', key: 'Ключ', capability: 'Права', capabilityMore: '+{count}', viewAllCapabilities: 'Показать все права', capabilityHint: 'Права пространства применяются к X-Tenant-ID, системные права — к API управления платформой.', lastUsed: 'Последнее использование', createdAt: 'Создан', actions: 'Действия', never: 'Никогда', systemCapabilityGroup: 'Управление платформой', + capabilities: { tenantsRead: 'Просмотр пространств', tenantsManage: 'Управление пространствами', settingsRead: 'Просмотр системных настроек', settingsManage: 'Управление системными настройками', runtimeRead: 'Просмотр очередей', runtimeManage: 'Управление очередями', auditRead: 'Просмотр аудита' }, + capabilityHints: { tenantsRead: 'Список, поиск и просмотр всех пространств.', tenantsManage: 'Создание, изменение и удаление пространств.', settingsRead: 'Чтение настроек платформы.', settingsManage: 'Изменение настроек платформы.', runtimeRead: 'Просмотр очередей и задач.', runtimeManage: 'Повтор, запуск, отмена и удаление задач.', auditRead: 'Чтение системного журнала аудита.' }, + createdTitle: 'Платформенный API-ключ создан', createdDescription: 'Скопируйте и сохраните ключ сейчас. Полное значение больше не будет показано.', copy: 'Копировать', copySuccess: 'Ключ скопирован', + deleteConfirm: 'Удалить «{name}»? Автоматизация с этим ключом сразу перестанет работать.', deleteSuccess: 'Платформенный API-ключ удалён', deleteFailed: 'Не удалось удалить API-ключ', nameRequired: 'Введите название', capabilityRequired: 'Выберите хотя бы одно право', loadFailed: 'Не удалось загрузить ключи', createFailed: 'Не удалось создать ключ', + }, } diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index cfa75d5e..021574a6 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3026,6 +3026,8 @@ export default { action: { "system.setting_changed": "系统设置变更", "system.admin_promoted": "授予系统管理员", + "system.api_key_created": "创建平台 API Key", + "system.api_key_revoked": "吊销平台 API Key", "system.admin_revoked": "回收系统管理员", "system.user_password_reset": "重置用户密码", "system.queue_task_retried": "重新执行失败任务", @@ -6914,4 +6916,20 @@ export default { generic: "操作失败,请稍后重试。", }, }, + platformApiKeys: { + title: "平台 API Key", + description: "为跨空间自动化创建平台级凭据;调用空间接口时通过 X-Tenant-ID 指定目标空间。", + securityNotice: "平台 API Key 默认可选择任意空间。请只授予必要能力;密钥明文仅在创建时显示一次。", + create: "创建平台 API Key", createDescription: "平台 Key 不绑定空间,所有操作仍受能力范围限制。", + loading: "正在加载…", empty: "暂无平台 API Key", name: "名称", namePlaceholder: "例如:集中运维自动化", + key: "密钥", capability: "能力授权", capabilityMore: "+{count}", viewAllCapabilities: "查看全部能力授权", capabilityHint: "空间能力对任意 X-Tenant-ID 生效;系统能力用于平台控制面接口。", + lastUsed: "最后使用", createdAt: "创建时间", actions: "操作", never: "从未使用", + systemCapabilityGroup: "平台控制面", + capabilities: { tenantsRead: "查看空间", tenantsManage: "管理空间", settingsRead: "查看系统设置", settingsManage: "管理系统设置", runtimeRead: "查看运行时", runtimeManage: "管理运行时", auditRead: "查看系统审计" }, + capabilityHints: { tenantsRead: "列出、搜索和查看所有空间。", tenantsManage: "创建、更新、删除空间及应用全局空间配置。", settingsRead: "读取平台级运行设置。", settingsManage: "更新和重置平台级运行设置。", runtimeRead: "查看任务队列和任务详情。", runtimeManage: "重试、立即执行、取消或删除运行时任务。", auditRead: "读取平台级审计日志。" }, + createdTitle: "平台 API Key 已创建", createdDescription: "请立即复制并安全保存。关闭后将不再显示完整密钥。", + copy: "复制密钥", copySuccess: "密钥已复制", + deleteConfirm: "确定删除“{name}”吗?使用该 Key 的自动化将立即失效。", deleteSuccess: "平台 API Key 已删除", deleteFailed: "删除平台 API Key 失败", + nameRequired: "请输入名称", capabilityRequired: "请至少选择一项能力", loadFailed: "加载平台 API Key 失败", createFailed: "创建平台 API Key 失败", + }, }; diff --git a/frontend/src/views/integrations/ApiIntegrationSettings.vue b/frontend/src/views/integrations/ApiIntegrationSettings.vue index 495dceba..7a1dc929 100644 --- a/frontend/src/views/integrations/ApiIntegrationSettings.vue +++ b/frontend/src/views/integrations/ApiIntegrationSettings.vue @@ -579,6 +579,13 @@ import { } from '@/api/tenant' import { listKnowledgeBases } from '@/api/knowledge-base' import { getApiBaseUrl } from '@/utils/api-base' +import { + DEFAULT_TENANT_API_KEY_CAPABILITIES, + KB_SCOPED_API_KEY_CAPABILITIES, + TENANT_API_KEY_CAPABILITIES, + TENANT_API_KEY_CAPABILITY_GROUPS, + type ApiKeyCapabilityGroup, +} from '@/config/apiKeyCapabilities' const { t } = useI18n() @@ -622,92 +629,10 @@ const form = reactive({ require_direct_header: false, }) -type ApiKeyCapabilityOption = { - value: TenantAPIKeyCapability - labelKey: string - hintKey: string -} -type ApiKeyCapabilityGroup = { - key: string - labelKey: string - capabilities: ApiKeyCapabilityOption[] -} - -const API_KEY_CAPABILITIES: TenantAPIKeyCapability[] = [ - 'retrieve', - 'chat', - 'read_agents', - 'ingest', - 'manage_kbs', - 'message_history', - 'manage_agents', - 'manage_mcp_services', - 'manage_datasources', - 'manage_models', - 'manage_vector_stores', - 'manage_storage_backends', - 'manage_web_search', - 'manage_channels', - 'run_evaluations', - 'manage_members', - 'manage_spaces', - 'manage_tenant_settings', -] - -const DEFAULT_API_KEY_CAPABILITIES = new Set(['retrieve', 'chat', 'read_agents']) -const KB_SCOPED_CAPABILITIES = new Set([ - 'retrieve', - 'chat', - 'ingest', - 'manage_kbs', - 'manage_agents', - 'manage_datasources', -]) - -const apiKeyCapabilityGroups: ApiKeyCapabilityGroup[] = [ - { - key: 'knowledge', - labelKey: 'integrations.api.apiKeyCapabilityGroupKnowledge', - capabilities: [ - { value: 'retrieve', labelKey: 'integrations.api.capabilityRetrieve', hintKey: 'integrations.api.capabilityRetrieveHint' }, - { value: 'chat', labelKey: 'integrations.api.capabilityChat', hintKey: 'integrations.api.capabilityChatHint' }, - { value: 'ingest', labelKey: 'integrations.api.capabilityIngest', hintKey: 'integrations.api.capabilityIngestHint' }, - { value: 'manage_kbs', labelKey: 'integrations.api.capabilityManageKbs', hintKey: 'integrations.api.capabilityManageKbsHint' }, - { value: 'message_history', labelKey: 'integrations.api.capabilityMessageHistory', hintKey: 'integrations.api.capabilityMessageHistoryHint' }, - ], - }, - { - key: 'automation', - labelKey: 'integrations.api.apiKeyCapabilityGroupAutomation', - capabilities: [ - { value: 'read_agents', labelKey: 'integrations.api.capabilityReadAgents', hintKey: 'integrations.api.capabilityReadAgentsHint' }, - { value: 'manage_agents', labelKey: 'integrations.api.capabilityManageAgents', hintKey: 'integrations.api.capabilityManageAgentsHint' }, - { value: 'manage_mcp_services', labelKey: 'integrations.api.capabilityManageMcpServices', hintKey: 'integrations.api.capabilityManageMcpServicesHint' }, - { value: 'manage_datasources', labelKey: 'integrations.api.capabilityManageDatasources', hintKey: 'integrations.api.capabilityManageDatasourcesHint' }, - ], - }, - { - key: 'collaboration', - labelKey: 'integrations.api.apiKeyCapabilityGroupCollaboration', - capabilities: [ - { value: 'manage_members', labelKey: 'integrations.api.capabilityManageMembers', hintKey: 'integrations.api.capabilityManageMembersHint' }, - { value: 'manage_spaces', labelKey: 'integrations.api.capabilityManageSpaces', hintKey: 'integrations.api.capabilityManageSpacesHint' }, - ], - }, - { - key: 'tenant', - labelKey: 'integrations.api.apiKeyCapabilityGroupTenant', - capabilities: [ - { value: 'manage_models', labelKey: 'integrations.api.capabilityManageModels', hintKey: 'integrations.api.capabilityManageModelsHint' }, - { value: 'manage_vector_stores', labelKey: 'integrations.api.capabilityManageVectorStores', hintKey: 'integrations.api.capabilityManageVectorStoresHint' }, - { value: 'manage_storage_backends', labelKey: 'integrations.api.capabilityManageStorageBackends', hintKey: 'integrations.api.capabilityManageStorageBackendsHint' }, - { value: 'manage_web_search', labelKey: 'integrations.api.capabilityManageWebSearch', hintKey: 'integrations.api.capabilityManageWebSearchHint' }, - { value: 'manage_channels', labelKey: 'integrations.api.capabilityManageChannels', hintKey: 'integrations.api.capabilityManageChannelsHint' }, - { value: 'run_evaluations', labelKey: 'integrations.api.capabilityRunEvaluations', hintKey: 'integrations.api.capabilityRunEvaluationsHint' }, - { value: 'manage_tenant_settings', labelKey: 'integrations.api.capabilityManageTenantSettings', hintKey: 'integrations.api.capabilityManageTenantSettingsHint' }, - ], - }, -] +const API_KEY_CAPABILITIES = TENANT_API_KEY_CAPABILITIES +const DEFAULT_API_KEY_CAPABILITIES = DEFAULT_TENANT_API_KEY_CAPABILITIES +const KB_SCOPED_CAPABILITIES = KB_SCOPED_API_KEY_CAPABILITIES +const apiKeyCapabilityGroups = TENANT_API_KEY_CAPABILITY_GROUPS const capabilitySelections = reactive>( API_KEY_CAPABILITIES.reduce((acc, capability) => { @@ -775,7 +700,7 @@ function toggleCapabilityGroup(group: ApiKeyCapabilityGroup, selected: boolean) // Full-access keys already cover every capability, so no capability badges for them. function keyCapabilityLabels(key: TenantAPIKey): string[] { if (key.full_access) return [] - const labels: Record = { + const labels: Partial> = { retrieve: t('integrations.api.capabilityRetrieve'), chat: t('integrations.api.capabilityChat'), read_agents: t('integrations.api.capabilityReadAgents'), diff --git a/frontend/src/views/settings/Settings.vue b/frontend/src/views/settings/Settings.vue index 8de2e21a..6ef6c017 100644 --- a/frontend/src/views/settings/Settings.vue +++ b/frontend/src/views/settings/Settings.vue @@ -144,6 +144,14 @@ +
+ +
+ +
+ +
+
@@ -201,6 +209,8 @@ import WeKnoraCloudSettings from './WeKnoraCloudSettings.vue' import TenantMembers from './TenantMembers.vue' import SystemSettings from '@/views/system/SystemSettings.vue' import RuntimeQueues from '@/views/system/RuntimeQueues.vue' +import PlatformAPIKeys from '@/views/system/PlatformAPIKeys.vue' +import SystemAuditLog from '@/views/system/SystemAuditLog.vue' import IntegrationSettingsSection from '@/views/integrations/IntegrationSettingsSection.vue' import { INTEGRATION_PREVIEW_ITEMS, @@ -265,7 +275,7 @@ const SECTION_MIN_ROLE: Record = { members: 'viewer', } -const SYSTEM_ADMIN_SECTIONS = new Set(['system-global', 'runtime-queues']) +const SYSTEM_ADMIN_SECTIONS = new Set(['system-global', 'runtime-queues', 'platform-api-keys', 'system-audit-log']) const INTEGRATION_SECTION_PREFIX = 'integration-' const integrationSectionKey = (tab: IntegrationTab) => `${INTEGRATION_SECTION_PREFIX}${tab}` @@ -336,6 +346,8 @@ const navItems = computed(() => { { key: 'system', icon: 'info-circle', label: t('settings.versionInfo') }, { key: 'system-global', icon: 'server', label: t('settings.system') }, { key: 'runtime-queues', icon: 'queue', label: t('settings.taskQueue') }, + { key: 'platform-api-keys', icon: 'secured', label: t('platformApiKeys.title') }, + { key: 'system-audit-log', icon: 'history', label: t('system.globalSettings.audit.tabLabel') }, { key: 'userprofile', icon: 'user', label: t('userProfile.title') }, { key: 'tenant', icon: 'user-circle', label: t('settings.tenantInfo') }, { key: 'members', icon: 'usergroup', label: t('tenantMember.title') }, @@ -398,7 +410,7 @@ const navGroups = computed(() => { { key: 'system_administration', label: t('settings.navGroups.systemAdministration'), - items: pickItems(['system-global', 'runtime-queues']), + items: pickItems(['system-global', 'runtime-queues', 'platform-api-keys', 'system-audit-log']), }, { key: 'platform', @@ -469,7 +481,7 @@ const handleClose = () => { // 如果当前路由是设置页,返回上一页 if (route.path === '/platform/settings') { const sec = route.query.section - if (sec === 'system-global' || sec === 'runtime-queues') { + if (sec === 'system-global' || sec === 'runtime-queues' || sec === 'platform-api-keys' || sec === 'system-audit-log') { router.push('/platform/knowledge-bases') } else { router.back() diff --git a/frontend/src/views/system/PlatformAPIKeys.vue b/frontend/src/views/system/PlatformAPIKeys.vue new file mode 100644 index 00000000..a9bba731 --- /dev/null +++ b/frontend/src/views/system/PlatformAPIKeys.vue @@ -0,0 +1,692 @@ + + + + + diff --git a/frontend/src/views/system/RuntimeQueues.vue b/frontend/src/views/system/RuntimeQueues.vue index 00ece976..c7737844 100644 --- a/frontend/src/views/system/RuntimeQueues.vue +++ b/frontend/src/views/system/RuntimeQueues.vue @@ -15,16 +15,19 @@ :aria-label="t('system.globalSettings.runtime.autoRefresh')" /> - - - {{ t('system.globalSettings.runtime.refresh') }} - + +
@@ -961,7 +964,7 @@ onUnmounted(() => { h2 { margin: 0 0 8px; color: var(--td-text-color-primary); - font-size: 22px; + font-size: 20px; font-weight: 600; line-height: 1.3; letter-spacing: -0.01em; @@ -984,6 +987,54 @@ onUnmounted(() => { flex-shrink: 0; } +.rq-refresh { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--td-text-color-placeholder); + cursor: pointer; + transition: color 0.2s cubic-bezier(0.16, 1, 0.3, 1), background 0.2s cubic-bezier(0.16, 1, 0.3, 1); + + :deep(.t-icon) { + font-size: 12px; + } + + &:hover:not(:disabled) { + color: var(--td-brand-color); + background: var(--td-bg-color-secondarycontainer); + } + + &:active:not(:disabled) { + background: var(--td-bg-color-secondarycontainer); + } + + &:disabled { + cursor: default; + opacity: 0.7; + } +} + +.rq-refresh-spin { + animation: rq-refresh-rotate 0.8s linear infinite; +} + +@keyframes rq-refresh-rotate { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + .rq-auto-refresh { display: flex; align-items: center; diff --git a/frontend/src/views/system/SystemAuditLog.vue b/frontend/src/views/system/SystemAuditLog.vue new file mode 100644 index 00000000..ad22a34c --- /dev/null +++ b/frontend/src/views/system/SystemAuditLog.vue @@ -0,0 +1,755 @@ + + + + + diff --git a/frontend/src/views/system/SystemSettings.vue b/frontend/src/views/system/SystemSettings.vue index b91e6219..23566663 100644 --- a/frontend/src/views/system/SystemSettings.vue +++ b/frontend/src/views/system/SystemSettings.vue @@ -33,23 +33,7 @@ -->
-
-

{{ t('system.globalSettings.title') }}

- - - - {{ t('system.globalSettings.audit.tabLabel') }} - -
+

{{ t('system.globalSettings.title') }}

{{ t('system.globalSettings.description') }}

@@ -65,19 +49,9 @@
@@ -1860,341 +1321,12 @@ onUnmounted(() => { } } -/* Title + audit-log entry sit on the same row, parallel to the layout - used in tenant member settings — keeps secondary actions anchored to - the section header instead of floating loose above content. */ -.section-header-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - - h2 { - margin: 0; - } -} - -.header-audit-btn { - flex-shrink: 0; -} - -/* ===== Audit drawer (mirrors TenantMembers.vue's audit panel) ========= - Kept scoped to this view rather than extracted to a shared component: - the two pages render distinct action labels and target formatters, - and a generic would have to thread enough props - through to make the abstraction more expensive than the duplication. - Revisit if a third audit surface appears. */ -.audit-panel { - display: flex; - flex-direction: column; - gap: 14px; - padding-top: 8px; -} - -.audit-panel--drawer { - padding-top: 0; -} - -.audit-header { - display: flex; - align-items: center; - justify-content: space-between; - background: var(--td-bg-color-secondarycontainer); - padding: 12px 16px; - border-radius: 8px; - gap: 12px; - - .audit-desc { - flex: 1; - min-width: 0; - font-size: 13px; - color: var(--td-text-color-secondary); - } - - .audit-refresh-btn { - flex-shrink: 0; - } -} - -.audit-drawer-inner { - display: flex; - flex-direction: column; - flex: 1 1 auto; - gap: 14px; - min-height: 0; - width: 100%; - box-sizing: border-box; -} - -.audit-drawer-fill { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; -} - -.audit-drawer-branch { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; -} - -.audit-drawer-branch--error { - justify-content: center; - - .error-inline { - width: 100%; - display: flex; - align-items: center; - gap: 8px; - padding: 20px 0 8px; - } -} - -.audit-drawer-branch--empty.empty-state--audit { - flex: 1 1 auto; - justify-content: center; - align-items: center; - padding: 24px 12px; - min-height: 0; -} - -.audit-scroll-area { - flex: 1 1 auto; - min-height: 0; - overflow-x: hidden; - overflow-y: auto; -} - -.audit-load-sentinel { - height: 1px; - width: 100%; - pointer-events: none; -} - -.audit-loading-more { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 12px; - font-size: 12px; - color: var(--td-text-color-secondary); -} - -.audit-end-hint { - text-align: center; - font-size: 12px; - color: var(--td-text-color-disabled); - padding: 8px 0 14px; - margin: 0; -} - -.audit-time { - display: flex; - flex-direction: column; - gap: 2px; - line-height: 1.3; - - .audit-time-date { - font-size: 12px; - color: var(--td-text-color-secondary); - } - - .audit-time-clock { - font-size: 13px; - font-weight: 500; - color: var(--td-text-color-primary); - font-variant-numeric: tabular-nums; - } -} - -.audit-actor { - display: flex; - flex-direction: column; - gap: 2px; - line-height: 1.3; - min-width: 0; - - .audit-actor-name { - font-size: 13px; - font-weight: 500; - color: var(--td-text-color-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .audit-actor-role { - font-size: 12px; - color: var(--td-text-color-secondary); - } -} - -.audit-target { - display: flex; - flex-direction: column; - gap: 4px; - line-height: 1.35; - min-width: 0; - padding: 2px 0; - - .audit-target-key { - font-size: 13px; - font-weight: 500; - color: var(--td-text-color-primary); - word-break: break-all; - font-family: var(--td-font-family-mono, monospace); - } - - .audit-target-diff { - font-size: 12px; - color: var(--td-text-color-secondary); - font-family: var(--td-font-family-mono, monospace); - word-break: break-all; - line-height: 1.4; - } - - .audit-target-empty { - color: var(--td-text-color-placeholder); - } -} - -/* Expanded row: surfaces the raw audit row context (UUIDs, target - type/id, full details JSON) so an investigator never has to hop to - psql for the verbatim event. Background steps off-card to make the - nested context visually distinct from the table rows. */ -.audit-expanded { - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px 16px; - background: var(--td-bg-color-container-hover); -} - -.audit-expanded-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 10px 18px; -} - -.audit-expanded-cell { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.audit-expanded-label { - font-size: 11px; - font-weight: 600; - color: var(--td-text-color-secondary); - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.audit-expanded-value { - font-size: 12px; - color: var(--td-text-color-primary); - word-break: break-all; -} - -.audit-expanded-details { - display: flex; - flex-direction: column; - gap: 4px; -} - -.audit-expanded-json { - margin: 0; - padding: 10px 12px; - font-size: 12px; - line-height: 1.55; - color: var(--td-text-color-primary); - background: var(--td-bg-color-container); - border: 1px solid var(--td-component-stroke); - border-radius: 6px; - white-space: pre-wrap; - word-break: break-all; - max-height: 280px; - overflow: auto; -} - -.mono { - font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); -} - -.data-table-shell { - overflow-x: auto; - border-radius: 10px; - border: 1px solid var(--td-component-stroke); - background-color: var(--td-bg-color-container); - - &:deep(thead th) { - font-weight: 600; - font-size: 13px; - background-color: var(--td-bg-color-secondarycontainer) !important; - } - - &:deep(.t-table td), - &:deep(.t-table th) { - padding-top: 14px; - padding-bottom: 14px; - /* Center the cell content vertically: most rows have at least one - single-line tag column (action / outcome), and a top-aligned - layout floats those chips above the multi-line target cell — - middle keeps the row's visual weight unified. */ - vertical-align: middle; - } -} - -/* Audit-specific table polish: no zebra stripes (the per-row "key / - diff" stack already provides enough separation between rows; stripes - on top read as visual noise), softer hover, denser separator. */ -.audit-table-shell { - /* Sticky table head: long audit feeds (50+ rows) lose the column - labels once the user scrolls, which makes "what's this column?" - a constant relearn. The drawer's outer scroll container is - `.audit-scroll-area`, so `top: 0` here pins thead to that - container's top. z-index keeps it above row hover/expand - backgrounds, and the explicit background plus bottom border - prevent row content bleeding through during scroll. */ - &:deep(thead th) { - position: sticky; - top: 0; - z-index: 2; - box-shadow: inset 0 -1px 0 var(--td-component-stroke); - } - - &:deep(.t-table tbody tr:hover > td) { - background-color: var(--td-bg-color-container-hover); - } - - &:deep(.t-table tbody tr.t-table__expanded-row > td) { - padding: 0 !important; - background-color: transparent; - } - - &:deep(.t-table__expandable-icon-cell) { - width: 36px; - } -} - -.settings-overview { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; - min-height: 24px; -} .auto-save-note { display: flex; align-items: center; gap: 6px; + margin-bottom: 10px; font-size: 13px; color: var(--td-text-color-secondary); @@ -2203,12 +1335,6 @@ onUnmounted(() => { } } -.settings-overview-tags { - display: flex; - align-items: center; - gap: 8px; -} - .config-source-details { margin-bottom: 18px; border: 1px solid var(--td-component-stroke); @@ -2544,7 +1670,6 @@ onUnmounted(() => { } @media (max-width: 860px) { - .settings-overview, .settings-section-intro { align-items: flex-start; flex-direction: column; @@ -2698,23 +1823,4 @@ onUnmounted(() => { } } -/* t-drawer teleports its content-wrapper to body, so the height-chain - needed for the internal scroll area must be declared globally. Same - pattern as `.tenant-members-audit-drawer` in TenantMembers.vue. */ -.t-drawer.system-settings-audit-drawer.t-drawer--right .t-drawer__content-wrapper--right { - box-sizing: border-box; - display: flex; - flex-direction: column; - max-height: 100vh; - height: 100%; -} - -.t-drawer.system-settings-audit-drawer .t-drawer__body { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - box-sizing: border-box; - overflow: hidden !important; -} diff --git a/internal/application/repository/tenant_api_key.go b/internal/application/repository/tenant_api_key.go index c6b7b5c0..61ab0229 100644 --- a/internal/application/repository/tenant_api_key.go +++ b/internal/application/repository/tenant_api_key.go @@ -47,6 +47,15 @@ func (r *tenantAPIKeyRepository) ListAPIKeys(ctx context.Context, tenantID uint6 return keys, err } +func (r *tenantAPIKeyRepository) ListPlatformAPIKeys(ctx context.Context) ([]*types.TenantAPIKey, error) { + var keys []*types.TenantAPIKey + err := r.db.WithContext(ctx). + Where("scope_type = ? AND revoked_at IS NULL", types.APIKeyScopePlatform). + Order("created_at DESC"). + Find(&keys).Error + return keys, err +} + func (r *tenantAPIKeyRepository) RevokeAPIKey(ctx context.Context, tenantID uint64, id uint64) error { now := time.Now() res := r.db.WithContext(ctx). @@ -62,6 +71,21 @@ func (r *tenantAPIKeyRepository) RevokeAPIKey(ctx context.Context, tenantID uint return nil } +func (r *tenantAPIKeyRepository) RevokePlatformAPIKey(ctx context.Context, id uint64) error { + now := time.Now() + res := r.db.WithContext(ctx). + Model(&types.TenantAPIKey{}). + Where("id = ? AND scope_type = ? AND revoked_at IS NULL", id, types.APIKeyScopePlatform). + Update("revoked_at", &now) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrTenantAPIKeyNotFound + } + return nil +} + func (r *tenantAPIKeyRepository) UpdateAPIKeyHash(ctx context.Context, id uint64, hash string) error { return r.db.WithContext(ctx). Model(&types.TenantAPIKey{}). diff --git a/internal/application/service/tenant_api_key.go b/internal/application/service/tenant_api_key.go index f3d7faf7..e0eabdaf 100644 --- a/internal/application/service/tenant_api_key.go +++ b/internal/application/service/tenant_api_key.go @@ -34,9 +34,17 @@ func NewTenantAPIKeyService(repo interfaces.TenantAPIKeyRepository) interfaces.T func (s *tenantAPIKeyService) CreateAPIKey( ctx context.Context, req interfaces.TenantAPIKeyCreateRequest, ) (*interfaces.TenantAPIKeyCreateResult, error) { - if req.TenantID == 0 { + scopeType := types.NormalizeAPIKeyScopeType(req.ScopeType) + if scopeType == types.APIKeyScopeTenant && req.TenantID == 0 { return nil, errors.New("tenant_id is required") } + if scopeType == types.APIKeyScopePlatform && req.FullAccess { + return nil, errors.New("platform API keys require explicit capabilities") + } + capabilities := types.NormalizeAPIKeyCapabilities(types.StringArray(req.Capabilities)) + if scopeType == types.APIKeyScopePlatform && len(capabilities) == 0 { + return nil, errors.New("platform API keys require at least one capability") + } name := strings.TrimSpace(req.Name) if name == "" { return nil, errors.New("name is required") @@ -45,14 +53,19 @@ func (s *tenantAPIKeyService) CreateAPIKey( if err != nil { return nil, err } + var tenantID *uint64 + if scopeType == types.APIKeyScopeTenant { + tenantID = &req.TenantID + } key := &types.TenantAPIKey{ - TenantID: req.TenantID, + TenantID: tenantID, + ScopeType: scopeType, Name: name, KeyHash: hashTenantAPIKey(token), APIKey: token, FullAccess: req.FullAccess, KnowledgeBaseIDs: normalizeAPIKeyIDs(req.KnowledgeBaseIDs), - Capabilities: types.NormalizeAPIKeyCapabilities(types.StringArray(req.Capabilities)), + Capabilities: capabilities, ExpiresAt: req.ExpiresAt, } if key.FullAccess { @@ -108,10 +121,18 @@ func (s *tenantAPIKeyService) ListAPIKeys(ctx context.Context, tenantID uint64) return s.repo.ListAPIKeys(ctx, tenantID) } +func (s *tenantAPIKeyService) ListPlatformAPIKeys(ctx context.Context) ([]*types.TenantAPIKey, error) { + return s.repo.ListPlatformAPIKeys(ctx) +} + func (s *tenantAPIKeyService) RevokeAPIKey(ctx context.Context, tenantID uint64, id uint64) error { return s.repo.RevokeAPIKey(ctx, tenantID, id) } +func (s *tenantAPIKeyService) RevokePlatformAPIKey(ctx context.Context, id uint64) error { + return s.repo.RevokePlatformAPIKey(ctx, id) +} + func (s *tenantAPIKeyService) BackfillMissingKeyHashes(ctx context.Context) (int, error) { has, err := s.repo.HasKeysWithPlaceholderHash(ctx) if err != nil { diff --git a/internal/application/service/tenant_api_key_test.go b/internal/application/service/tenant_api_key_test.go index 862dea5b..9c77c5f1 100644 --- a/internal/application/service/tenant_api_key_test.go +++ b/internal/application/service/tenant_api_key_test.go @@ -66,7 +66,18 @@ func (r *fakeTenantAPIKeyRepo) GetAPIKeyByHash(_ context.Context, hash string) ( func (r *fakeTenantAPIKeyRepo) ListAPIKeys(_ context.Context, tenantID uint64) ([]*types.TenantAPIKey, error) { out := []*types.TenantAPIKey{} for _, key := range r.byHash { - if key.TenantID == tenantID && key.RevokedAt == nil { + if key.TenantIDValue() == tenantID && key.RevokedAt == nil { + cp := *key + out = append(out, &cp) + } + } + return out, nil +} + +func (r *fakeTenantAPIKeyRepo) ListPlatformAPIKeys(_ context.Context) ([]*types.TenantAPIKey, error) { + out := []*types.TenantAPIKey{} + for _, key := range r.byHash { + if key.IsPlatform() && key.RevokedAt == nil { cp := *key out = append(out, &cp) } @@ -77,7 +88,18 @@ func (r *fakeTenantAPIKeyRepo) ListAPIKeys(_ context.Context, tenantID uint64) ( func (r *fakeTenantAPIKeyRepo) RevokeAPIKey(_ context.Context, tenantID uint64, id uint64) error { now := time.Now() for _, key := range r.byHash { - if key.ID == id && key.TenantID == tenantID && key.RevokedAt == nil { + if key.ID == id && key.TenantIDValue() == tenantID && key.RevokedAt == nil { + key.RevokedAt = &now + return nil + } + } + return apprepo.ErrTenantAPIKeyNotFound +} + +func (r *fakeTenantAPIKeyRepo) RevokePlatformAPIKey(_ context.Context, id uint64) error { + now := time.Now() + for _, key := range r.byHash { + if key.ID == id && key.IsPlatform() && key.RevokedAt == nil { key.RevokedAt = &now return nil } @@ -134,7 +156,7 @@ func TestTenantAPIKeyServiceBackfillMissingKeyHashes(t *testing.T) { token := "sk-legacy-token-value" legacy := &types.TenantAPIKey{ - TenantID: 7, + TenantID: uint64Pointer(7), Name: "legacy", KeyHash: "migrated-tenant-7", APIKey: token, @@ -159,6 +181,38 @@ func TestTenantAPIKeyServiceBackfillMissingKeyHashes(t *testing.T) { } } +func uint64Pointer(value uint64) *uint64 { return &value } + +func TestTenantAPIKeyServiceCreatesPlatformKeyWithoutTenant(t *testing.T) { + repo := newFakeTenantAPIKeyRepo() + svc := NewTenantAPIKeyService(repo) + created, err := svc.CreateAPIKey(context.Background(), interfaces.TenantAPIKeyCreateRequest{ + ScopeType: types.APIKeyScopePlatform, + Name: "automation", + Capabilities: []string{string(types.APIKeyCapabilityRetrieve)}, + }) + if err != nil { + t.Fatalf("CreateAPIKey returned error: %v", err) + } + if !created.APIKey.IsPlatform() || created.APIKey.TenantID != nil { + t.Fatalf("created key scope = %q tenant=%v, want platform with nil tenant", created.APIKey.ScopeType, created.APIKey.TenantID) + } + if created.APIKey.FullAccess { + t.Fatal("platform API key must not be full-access") + } +} + +func TestTenantAPIKeyServiceRejectsFullAccessPlatformKey(t *testing.T) { + svc := NewTenantAPIKeyService(newFakeTenantAPIKeyRepo()) + _, err := svc.CreateAPIKey(context.Background(), interfaces.TenantAPIKeyCreateRequest{ + ScopeType: types.APIKeyScopePlatform, + Name: "unsafe", FullAccess: true, + }) + if err == nil { + t.Fatal("full-access platform key should be rejected") + } +} + func TestTenantAPIKeyServiceRevokeAPIKey(t *testing.T) { ctx := context.Background() repo := newFakeTenantAPIKeyRepo() diff --git a/internal/handler/system.go b/internal/handler/system.go index b167f276..b3ab7717 100644 --- a/internal/handler/system.go +++ b/internal/handler/system.go @@ -45,6 +45,7 @@ type SystemHandler struct { tenantSvc interfaces.TenantService userSvc interfaces.UserService systemSettingSvc interfaces.SystemSettingService + apiKeySvc interfaces.TenantAPIKeyService // auditSvc is optional — when nil, emitAdminAudit no-ops so unit // tests that wire a partial container still compile. In production // the dig graph always provides one. @@ -72,6 +73,7 @@ func NewSystemHandler(cfg *config.Config, tenantSvc interfaces.TenantService, userSvc interfaces.UserService, systemSettingSvc interfaces.SystemSettingService, + apiKeySvc interfaces.TenantAPIKeyService, auditSvc interfaces.AuditLogService, taskInspector interfaces.TaskInspector, knowledgeSvc interfaces.KnowledgeService, @@ -84,6 +86,7 @@ func NewSystemHandler(cfg *config.Config, tenantSvc: tenantSvc, userSvc: userSvc, systemSettingSvc: systemSettingSvc, + apiKeySvc: apiKeySvc, auditSvc: auditSvc, taskInspector: taskInspector, knowledgeSvc: knowledgeSvc, @@ -91,6 +94,142 @@ func NewSystemHandler(cfg *config.Config, } } +type platformAPIKeyCreateRequest struct { + Name string `json:"name"` + Capabilities []string `json:"capabilities"` + ExpiresAt *int64 `json:"expires_at_unix"` +} + +// ListPlatformAPIKeys godoc +// @Summary List platform API keys +// @Description Lists non-workspace API keys. Full tokens are always masked. Human SystemAdmin only. +// @Tags System Admin +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Security Bearer +// @Router /system/admin/api-keys [get] +func (h *SystemHandler) ListPlatformAPIKeys(c *gin.Context) { + keys, err := h.apiKeySvc.ListPlatformAPIKeys(c.Request.Context()) + if err != nil { + c.Error(apperrors.NewInternalServerError("Failed to list platform API keys").WithDetails(err.Error())) + return + } + response := make([]tenantAPIKeyResponse, 0, len(keys)) + for _, key := range keys { + item := tenantAPIKeyForResponse(key) + item.APIKey = maskManagedAPIKey(key.APIKey) + response = append(response, item) + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": response}) +} + +// CreatePlatformAPIKey godoc +// @Summary Create a platform API key +// @Description Creates a capability-scoped key that may target any workspace through X-Tenant-ID. The plaintext token is returned once. Human SystemAdmin only. +// @Tags System Admin +// @Accept json +// @Produce json +// @Param request body platformAPIKeyCreateRequest true "Platform API key" +// @Success 201 {object} map[string]interface{} +// @Security Bearer +// @Router /system/admin/api-keys [post] +func (h *SystemHandler) CreatePlatformAPIKey(c *gin.Context) { + var req platformAPIKeyCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(apperrors.NewValidationError("Invalid request data").WithDetails(err.Error())) + return + } + if strings.TrimSpace(req.Name) == "" { + c.Error(apperrors.NewValidationError("name is required")) + return + } + capabilities := types.NormalizeAPIKeyCapabilities(types.StringArray(req.Capabilities)) + if len(capabilities) == 0 || len(capabilities) != len(req.Capabilities) { + c.Error(apperrors.NewValidationError("valid capabilities are required")) + return + } + var expiresAt *time.Time + if req.ExpiresAt != nil { + value := time.Unix(*req.ExpiresAt, 0) + if !value.After(time.Now()) { + c.Error(apperrors.NewValidationError("expires_at_unix must be in the future")) + return + } + expiresAt = &value + } + result, err := h.apiKeySvc.CreateAPIKey(c.Request.Context(), interfaces.TenantAPIKeyCreateRequest{ + ScopeType: types.APIKeyScopePlatform, + Name: req.Name, + Capabilities: []string(capabilities), + ExpiresAt: expiresAt, + }) + if err != nil { + c.Error(apperrors.NewInternalServerError("Failed to create platform API key").WithDetails(err.Error())) + return + } + item := tenantAPIKeyForResponse(result.APIKey) + item.APIKey = maskManagedAPIKey(result.Token) + h.emitAPIKeyAudit(c.Request.Context(), types.AuditActionSystemAPIKeyCreated, result.APIKey) + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "data": tenantAPIKeyCreateResponse{tenantAPIKeyResponse: item, Token: result.Token}, + }) +} + +// DeletePlatformAPIKey godoc +// @Summary Revoke a platform API key +// @Description Immediately revokes a platform API key. Human SystemAdmin only. +// @Tags System Admin +// @Produce json +// @Param key_id path int true "API key ID" +// @Success 200 {object} map[string]interface{} +// @Security Bearer +// @Router /system/admin/api-keys/{key_id} [delete] +func (h *SystemHandler) DeletePlatformAPIKey(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("key_id"), 10, 64) + if err != nil || id == 0 { + c.Error(apperrors.NewBadRequestError("Invalid API key ID")) + return + } + if err := h.apiKeySvc.RevokePlatformAPIKey(c.Request.Context(), id); err != nil { + c.Error(apperrors.NewNotFoundError("Platform API key not found")) + return + } + h.emitAPIKeyAudit(c.Request.Context(), types.AuditActionSystemAPIKeyRevoked, &types.TenantAPIKey{ID: id}) + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func maskManagedAPIKey(token string) string { + token = strings.TrimSpace(token) + if len(token) <= 12 { + return "***" + } + return token[:7] + "..." + token[len(token)-4:] +} + +func (h *SystemHandler) emitAPIKeyAudit(ctx context.Context, action types.AuditAction, key *types.TenantAPIKey) { + if h.auditSvc == nil || key == nil { + return + } + actorID, _ := types.UserIDFromContext(ctx) + details, _ := json.Marshal(map[string]any{ + "scope_type": types.APIKeyScopePlatform, + "capabilities": key.Capabilities, + }) + _ = h.auditSvc.Log(ctx, &types.AuditLog{ + TenantID: 0, ActorUserID: actorID, ActorRole: systemAuditActorRole(ctx), + Action: action, TargetType: "api_key", TargetID: strconv.FormatUint(key.ID, 10), + Outcome: types.AuditOutcomeSuccess, Details: types.JSON(details), + }) +} + +func systemAuditActorRole(ctx context.Context) string { + if scope, ok := types.TenantAPIKeyScopeFromContext(ctx); ok && scope.IsPlatform() { + return "platform_api_key" + } + return "system_admin" +} + // emitAdminAudit writes one audit row for a system-admin lifecycle event // (promote / revoke). Best-effort — a nil audit service or a write // failure does not bubble up to the caller. Mirrors the failure @@ -120,7 +259,7 @@ func (h *SystemHandler) emitAdminAudit( // events (matches AuditActionSystemSettingChanged). TenantID: 0, ActorUserID: actorID, - ActorRole: "system_admin", + ActorRole: systemAuditActorRole(ctx), Action: action, TargetType: "user", Outcome: types.AuditOutcomeSuccess, @@ -1773,7 +1912,7 @@ func (h *SystemHandler) emitQueueTaskAudit( _ = h.auditSvc.Log(ctx, &types.AuditLog{ TenantID: 0, ActorUserID: actorID, - ActorRole: "system_admin", + ActorRole: systemAuditActorRole(ctx), Action: action, TargetType: targetType, TargetID: targetID, @@ -2122,7 +2261,7 @@ func (h *SystemHandler) ApplyDefaultStorageQuotaToAllTenants(c *gin.Context) { _ = h.auditSvc.Log(ctx, &types.AuditLog{ TenantID: 0, ActorUserID: actorID, - ActorRole: "system_admin", + ActorRole: systemAuditActorRole(ctx), Action: types.AuditActionSystemSettingChanged, TargetType: "tenant_storage_quota", TargetID: "all", diff --git a/internal/handler/tenant.go b/internal/handler/tenant.go index 3f7c6396..bae6d4f8 100644 --- a/internal/handler/tenant.go +++ b/internal/handler/tenant.go @@ -144,15 +144,16 @@ type tenantAPIKeyCreateRequest struct { } type tenantAPIKeyResponse struct { - ID uint64 `json:"id"` - Name string `json:"name"` - APIKey string `json:"api_key"` - FullAccess bool `json:"full_access"` - KnowledgeBaseIDs types.StringArray `json:"knowledge_base_ids"` - Capabilities types.StringArray `json:"capabilities"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `json:"id"` + ScopeType types.APIKeyScopeType `json:"scope_type"` + Name string `json:"name"` + APIKey string `json:"api_key"` + FullAccess bool `json:"full_access"` + KnowledgeBaseIDs types.StringArray `json:"knowledge_base_ids"` + Capabilities types.StringArray `json:"capabilities"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` } type tenantAPIKeyCreateResponse struct { @@ -227,12 +228,15 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) { c.Error(errors.NewUnauthorizedError("authentication required")) return } + apiKeyScope, hasAPIKeyScope := types.TenantAPIKeyScopeFromContext(ctx) + platformCaller := hasAPIKeyScope && apiKeyScope.IsPlatform() + catalogManager := caller.CanAccessAllTenants || platformCaller // Deployment-level policy: ordinary users may be restricted to joining // existing workspaces by invitation. This check is authoritative; the // frontend capability only improves UX and cannot bypass it. Cross-tenant // superusers retain the catalog-management create path. - if !caller.CanAccessAllTenants && + if !catalogManager && !resolveTenantSelfServiceCreationEnabled(ctx, h.config, h.systemSettingSvc) { logger.Warnf(ctx, "Self-service tenant creation denied by policy for user %s", caller.ID) c.Error(errors.NewTenantCreationDisabledError()) @@ -241,7 +245,7 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) { var tenantData types.Tenant - if caller.CanAccessAllTenants { + if catalogManager { // Backward-compatible path for cross-tenant superusers: accept // the full Tenant payload (status, storage_quota, retriever // engines, configs...) so existing tooling keeps working. @@ -352,7 +356,7 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) { // yet still occupies storage_bucket / name uniqueness slots. // Idempotent: EnsureOwner is a no-op when the row already exists, // so cross-tenant superusers create-and-own through the same path. - if h.memberService != nil { + if h.memberService != nil && !platformCaller { if _, err := h.memberService.EnsureOwner(ctx, caller.ID, createdTenant.ID); err != nil { logger.Errorf(ctx, "Failed to bootstrap owner membership for user %s tenant %d: %v — rolling back tenant", @@ -416,7 +420,7 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) { // When a tenantless user creates their first workspace, make it their // default login tenant. Roll the just-created resources back if this // finalisation fails so the user is not left with an unreachable tenant. - if caller.TenantID == 0 { + if caller.TenantID == 0 && !platformCaller { caller.TenantID = createdTenant.ID if err := h.userService.UpdateUser(ctx, caller); err != nil { logger.Errorf(ctx, "Failed to set first tenant %d as default for user %s: %v", @@ -450,7 +454,10 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) { // tenant_api_keys. Failing to create the convenience key must NOT fail // the whole tenant creation (the tenant is fully usable without a key); // we log a warning and return the tenant as usual. - if h.autoCreateTenantAPIKey(ctx) && h.apiKeyService != nil { + // A platform key with system_tenants_manage may create workspaces, but it + // must not bootstrap a full-access workspace key and escalate beyond its + // own explicit capabilities. + if !platformCaller && h.autoCreateTenantAPIKey(ctx) && h.apiKeyService != nil { result, keyErr := h.apiKeyService.CreateAPIKey(ctx, interfaces.TenantAPIKeyCreateRequest{ TenantID: createdTenant.ID, Name: "default", @@ -731,6 +738,7 @@ func tenantAPIKeyForResponse(key *types.TenantAPIKey) tenantAPIKeyResponse { } return tenantAPIKeyResponse{ ID: key.ID, + ScopeType: types.NormalizeAPIKeyScopeType(key.ScopeType), Name: key.Name, APIKey: key.APIKey, FullAccess: key.FullAccess, diff --git a/internal/middleware/access.go b/internal/middleware/access.go index 1c91adf6..0d5ad034 100644 --- a/internal/middleware/access.go +++ b/internal/middleware/access.go @@ -111,6 +111,10 @@ func IsTenantAccessible( func RequireCrossTenantAccess(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { ctx := c.Request.Context() + if scope, ok := types.TenantAPIKeyScopeFromContext(ctx); ok && scope.IsPlatform() { + c.Next() + return + } // First the cluster-wide flag — if it's off, nobody gets through, // not even users with CanAccessAllTenants=true. This mirrors the // "must require BOTH" rule that previously lived in tenant.go. diff --git a/internal/middleware/api_key_gate.go b/internal/middleware/api_key_gate.go index a25e2b9d..3963e80c 100644 --- a/internal/middleware/api_key_gate.go +++ b/internal/middleware/api_key_gate.go @@ -21,6 +21,10 @@ var errTenantAPIKeyScopeForbidden = stderrors.New("workspace API key scope forbi // default (fail-closed), which removes the old "remember to add APIKeyDeny" // footgun. type APIKeyRoutePolicy struct { + // PlatformOnly rejects workspace-bound keys even when they are full-access. + // It is used for control-plane routes under /system/admin and cross-workspace + // tenant lifecycle APIs. + PlatformOnly bool // RequireFullAccess admits only full-access tenant API keys unless one of // the listed capabilities also matches. Routes with neither full-access // requirement nor capabilities are open to any valid API key. @@ -128,6 +132,12 @@ func (a *APIKeyRouteAuthorizer) authorize(scope types.TenantAPIKeyScope, method, if !ok { return errTenantAPIKeyScopeForbidden } + if policy.PlatformOnly && !scope.IsPlatform() { + return errTenantAPIKeyScopeForbidden + } + if policy.PlatformOnly && len(policy.Capabilities) == 0 { + return errTenantAPIKeyScopeForbidden + } if scope.FullAccess { return nil } diff --git a/internal/middleware/api_key_gate_test.go b/internal/middleware/api_key_gate_test.go index fceb4773..88ada28c 100644 --- a/internal/middleware/api_key_gate_test.go +++ b/internal/middleware/api_key_gate_test.go @@ -171,6 +171,35 @@ func TestGateKBScopeDoesNotBlockDataPlane(t *testing.T) { } } +func TestGatePlatformOnlyPolicyRejectsTenantKeyBeforeFullAccess(t *testing.T) { + a := NewAPIKeyRouteAuthorizer() + a.Register(http.MethodGet, "/api/v1/system/admin/settings", + APIKeyRoutePolicy{PlatformOnly: true}. + WithCapability(types.APIKeyCapabilitySystemSettingsRead)) + tenantFull := &types.TenantAPIKeyScope{FullAccess: true} + platform := &types.TenantAPIKeyScope{ + ScopeType: types.APIKeyScopePlatform, + Capabilities: types.StringArray{string(types.APIKeyCapabilitySystemSettingsRead)}, + } + if runGate(t, a, tenantFull, http.MethodGet, "/api/v1/system/admin/settings") { + t.Fatal("tenant full-access key must not enter a platform-only route") + } + if !runGate(t, a, platform, http.MethodGet, "/api/v1/system/admin/settings") { + t.Fatal("platform key with the required capability should pass") + } + + withoutCapability := NewAPIKeyRouteAuthorizer() + withoutCapability.Register(http.MethodGet, "/api/v1/system/admin/unsafe", + APIKeyRoutePolicy{PlatformOnly: true}) + corruptPlatformFull := &types.TenantAPIKeyScope{ + ScopeType: types.APIKeyScopePlatform, + FullAccess: true, + } + if runGate(t, withoutCapability, corruptPlatformFull, http.MethodGet, "/api/v1/system/admin/unsafe") { + t.Fatal("platform-only policy without an explicit capability must fail closed") + } +} + // runDenyAPIKey mounts DenyAPIKeyPrincipal ahead of a handler and reports // whether the request reached the handler. func runDenyAPIKey(t *testing.T, scope *types.TenantAPIKeyScope) (reached bool, status int) { diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ed36b5d5..add91394 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -337,7 +337,51 @@ func authenticateAPIKeyRequest( return false } - attachAPIKeyAuthContext(c, tenantService, userService, key.TenantID, key) + if key.IsPlatform() { + tenantHeader := strings.TrimSpace(c.GetHeader("X-Tenant-ID")) + if tenantHeader == "" { + if !isPlatformTenantOptionalAPI(c.Request.URL.Path, c.Request.Method) { + c.JSON(http.StatusConflict, gin.H{ + "error": "Workspace required: platform API keys must send X-Tenant-ID", + "code": "TENANT_REQUIRED", + }) + c.Abort() + return false + } + attachPlatformAPIKeyAuthContext(c, key) + } else { + targetTenantID, parseErr := strconv.ParseUint(tenantHeader, 10, 64) + if parseErr != nil || targetTenantID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid X-Tenant-ID header"}) + c.Abort() + return false + } + attachAPIKeyAuthContext(c, tenantService, userService, targetTenantID, key) + } + } else { + tenantID := key.TenantIDValue() + if tenantID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid API key scope"}) + c.Abort() + return false + } + if tenantHeader := strings.TrimSpace(c.GetHeader("X-Tenant-ID")); tenantHeader != "" { + requestedTenantID, parseErr := strconv.ParseUint(tenantHeader, 10, 64) + if parseErr != nil || requestedTenantID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid X-Tenant-ID header"}) + c.Abort() + return false + } + if requestedTenantID != tenantID { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Forbidden: workspace API key cannot switch workspaces", + }) + c.Abort() + return false + } + } + attachAPIKeyAuthContext(c, tenantService, userService, tenantID, key) + } if c.IsAborted() { return false } @@ -347,6 +391,55 @@ func authenticateAPIKeyRequest( return true } +func isPlatformTenantOptionalAPI(path, method string) bool { + path = strings.TrimSuffix(strings.TrimSpace(path), "/") + if strings.HasPrefix(path, "/api/v1/system/admin") { + return true + } + if method == http.MethodGet && (path == "/api/v1/tenants/all" || path == "/api/v1/tenants/search") { + return true + } + return method == http.MethodPost && path == "/api/v1/tenants" +} + +func attachPlatformAPIKeyAuthContext(c *gin.Context, key *types.TenantAPIKey) { + principal, user := platformAPIKeyIdentity(key) + userID := user.ID + c.Set(types.UserContextKey.String(), user) + c.Set(types.UserIDContextKey.String(), userID) + c.Set(types.PrincipalContextKey.String(), principal) + c.Set(types.TenantRoleContextKey.String(), types.TenantRoleViewer) + c.Set(types.SystemAdminContextKey.String(), false) + ctx := c.Request.Context() + ctx = context.WithValue(ctx, types.UserContextKey, user) + ctx = context.WithValue(ctx, types.UserIDContextKey, userID) + ctx = types.WithPrincipal(ctx, principal) + ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer) + ctx = context.WithValue(ctx, types.SystemAdminContextKey, false) + ctx = types.WithTenantAPIKeyScope(ctx, types.TenantAPIKeyScope{ + KeyID: key.ID, + ScopeType: types.APIKeyScopePlatform, + FullAccess: false, + Capabilities: key.Capabilities, + }) + c.Request = c.Request.WithContext(ctx) +} + +func platformAPIKeyIdentity(key *types.TenantAPIKey) (types.Principal, *types.User) { + keyID := uint64(0) + if key != nil { + keyID = key.ID + } + principal := types.Principal{Type: types.PrincipalAPIPlatform, ID: strconv.FormatUint(keyID, 10)} + userID := principal.StorageID() + return principal, &types.User{ + ID: userID, + Username: userID, + Email: fmt.Sprintf("platform-api-key-%d@api-key.local", keyID), + IsActive: true, + } +} + func attachAPIKeyAuthContext( c *gin.Context, tenantService interfaces.TenantService, @@ -369,32 +462,45 @@ func attachAPIKeyAuthContext( types.TenantInfoContextKey, t, ) - user, err := userService.GetUserByTenantID(c.Request.Context(), tenantID) - if err != nil || user == nil { - user = &types.User{ - ID: fmt.Sprintf("system-%d", tenantID), - Username: fmt.Sprintf("system-%d", tenantID), - Email: fmt.Sprintf("system-%d@api-key.local", tenantID), - TenantID: tenantID, - IsActive: true, + var user *types.User + var principal types.Principal + if key != nil && key.IsPlatform() { + // A platform key keeps one stable machine identity while selecting the + // target workspace through X-Tenant-ID. Tenant API-principal modes and + // tenant-owned synthetic users must not rewrite that identity. + principal, user = platformAPIKeyIdentity(key) + user.TenantID = tenantID + } else { + user, err = userService.GetUserByTenantID(c.Request.Context(), tenantID) + if err != nil || user == nil { + user = &types.User{ + ID: fmt.Sprintf("system-%d", tenantID), + Username: fmt.Sprintf("system-%d", tenantID), + Email: fmt.Sprintf("system-%d@api-key.local", tenantID), + TenantID: tenantID, + IsActive: true, + } + log.Printf("No user found for tenant %d via API key, using synthetic system user %s", tenantID, user.ID) + } + + var principalErr error + principal, principalErr = resolveAPIPrincipal(c.Request.Context(), t, c.Request.Header) + if principalErr != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": apiPrincipalAuthErrorMessage(principalErr)}) + c.Abort() + return } - log.Printf("No user found for tenant %d via API key, using synthetic system user %s", tenantID, user.ID) } c.Set(types.UserContextKey.String(), user) c.Set(types.UserIDContextKey.String(), user.ID) - principal, principalErr := resolveAPIPrincipal(c.Request.Context(), t, c.Request.Header) - if principalErr != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": apiPrincipalAuthErrorMessage(principalErr)}) - c.Abort() - return - } c.Set(types.PrincipalContextKey.String(), principal) // This role context exists only for legacy guard compatibility after // RequireRole short-circuits API-key principals. The API key's real // authority is FullAccess + Capabilities + KnowledgeBaseIDs. apiKeyTenantRoleContext := types.TenantRoleViewer - if key != nil && key.FullAccess { + fullAccess := key != nil && key.FullAccess && !key.IsPlatform() + if fullAccess { apiKeyTenantRoleContext = types.TenantRoleOwner } c.Set(types.TenantRoleContextKey.String(), apiKeyTenantRoleContext) @@ -407,7 +513,8 @@ func attachAPIKeyAuthContext( if key != nil { ctx = types.WithTenantAPIKeyScope(ctx, types.TenantAPIKeyScope{ KeyID: key.ID, - FullAccess: key.FullAccess, + ScopeType: key.ScopeType, + FullAccess: fullAccess, KnowledgeBaseIDs: key.KnowledgeBaseIDs, Capabilities: key.Capabilities, }) diff --git a/internal/middleware/auth_platform_api_key_test.go b/internal/middleware/auth_platform_api_key_test.go new file mode 100644 index 00000000..456ee9ee --- /dev/null +++ b/internal/middleware/auth_platform_api_key_test.go @@ -0,0 +1,95 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Tencent/WeKnora/internal/types" + "github.com/gin-gonic/gin" +) + +func TestPlatformTenantOptionalAPIs(t *testing.T) { + cases := []struct { + method string + path string + want bool + }{ + {http.MethodGet, "/api/v1/system/admin/settings", true}, + {http.MethodGet, "/api/v1/tenants/all", true}, + {http.MethodGet, "/api/v1/tenants/search", true}, + {http.MethodPost, "/api/v1/tenants", true}, + {http.MethodGet, "/api/v1/knowledge-bases", false}, + {http.MethodGet, "/api/v1/tenants", false}, + } + for _, tc := range cases { + if got := isPlatformTenantOptionalAPI(tc.path, tc.method); got != tc.want { + t.Fatalf("isPlatformTenantOptionalAPI(%s, %s) = %v, want %v", tc.method, tc.path, got, tc.want) + } + } +} + +func TestAttachPlatformAPIKeyAuthContext(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/system/admin/settings", nil) + attachPlatformAPIKeyAuthContext(c, &types.TenantAPIKey{ + ID: 9, ScopeType: types.APIKeyScopePlatform, + Capabilities: types.StringArray{string(types.APIKeyCapabilitySystemSettingsRead)}, + }) + scope, ok := types.TenantAPIKeyScopeFromContext(c.Request.Context()) + if !ok || !scope.IsPlatform() || scope.KeyID != 9 { + t.Fatalf("platform scope = %#v, ok=%v", scope, ok) + } + if _, ok := types.TenantIDFromContext(c.Request.Context()); ok { + t.Fatal("tenantless platform control-plane request must not have tenant context") + } + principal, ok := types.PrincipalFromContext(c.Request.Context()) + if !ok || principal.Type != types.PrincipalAPIPlatform { + t.Fatalf("principal = %#v, ok=%v", principal, ok) + } +} + +func TestPlatformAPIKeyIdentityIsStableAcrossTenants(t *testing.T) { + principal, user := platformAPIKeyIdentity(&types.TenantAPIKey{ID: 27}) + if principal.Type != types.PrincipalAPIPlatform || principal.ID != "27" { + t.Fatalf("principal = %#v", principal) + } + if user.ID != "api_platform:27" || user.Username != user.ID { + t.Fatalf("user = %#v", user) + } +} + +func TestAttachTargetedPlatformAPIKeyKeepsPlatformPrincipal(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/knowledge-bases", nil) + key := &types.TenantAPIKey{ + ID: 27, + ScopeType: types.APIKeyScopePlatform, + FullAccess: true, // Corrupt/legacy data must still be capability-only at runtime. + Capabilities: types.StringArray{ + string(types.APIKeyCapabilityRetrieve), + }, + } + attachAPIKeyAuthContext(c, &fakeTenantService{tenant: &types.Tenant{ID: 42}}, nil, 42, key) + if c.IsAborted() { + t.Fatal("targeted platform API key context unexpectedly aborted") + } + principal, ok := types.PrincipalFromContext(c.Request.Context()) + if !ok || principal.Type != types.PrincipalAPIPlatform || principal.ID != "27" { + t.Fatalf("principal = %#v, ok=%v", principal, ok) + } + tenantID, ok := types.TenantIDFromContext(c.Request.Context()) + if !ok || tenantID != 42 { + t.Fatalf("tenant id = %d, ok=%v", tenantID, ok) + } + scope, ok := types.TenantAPIKeyScopeFromContext(c.Request.Context()) + if !ok || !scope.IsPlatform() || scope.FullAccess { + t.Fatalf("scope = %#v, ok=%v", scope, ok) + } + user, ok := c.Request.Context().Value(types.UserContextKey).(*types.User) + if !ok || user.ID != "api_platform:27" || user.TenantID != 42 { + t.Fatalf("user = %#v, ok=%v", user, ok) + } +} diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 3f948dbf..2dc314ac 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -148,9 +148,14 @@ func RequireSystemAdmin(cfg *config.Config) gin.HandlerFunc { warnOnNilConfig(cfg) return func(c *gin.Context) { ctx := c.Request.Context() - // API-key principals must never reach system-admin routes, even if a - // future route registration mistakenly declares an apiKey* policy. - if _, ok := types.TenantAPIKeyScopeFromContext(ctx); ok { + // The API-key gate runs before this guard and default-denies undeclared + // routes. Only platform keys that passed an explicit platform capability + // policy may reuse the system-admin handlers below. + if scope, ok := types.TenantAPIKeyScopeFromContext(ctx); ok { + if scope.IsPlatform() { + c.Next() + return + } logger.Warnf(ctx, "[rbac] system admin required: API-key principal denied path=%s", c.Request.URL.Path) diff --git a/internal/middleware/rbac_api_key_shortcircuit_test.go b/internal/middleware/rbac_api_key_shortcircuit_test.go index caa562d6..f767ced3 100644 --- a/internal/middleware/rbac_api_key_shortcircuit_test.go +++ b/internal/middleware/rbac_api_key_shortcircuit_test.go @@ -55,6 +55,17 @@ func TestRequireSystemAdmin_RejectsAPIKey(t *testing.T) { } } +func TestRequireSystemAdmin_AllowsPlatformAPIKeyAfterRouteGate(t *testing.T) { + w := apiKeyRBACHarness( + types.TenantAPIKeyScope{ScopeType: types.APIKeyScopePlatform}, + types.TenantRoleViewer, + RequireSystemAdmin(cfgRBAC(true)), + ) + if w.Code != http.StatusOK { + t.Fatalf("platform API key should pass the system-admin role guard after route authorization, got %d", w.Code) + } +} + // TestRequireOwnershipOrRole_ShortCircuitsAPIKey is the core regression for // review #1: an API key writing to a KB it does not // "own" (synthetic system user never matches creator_id) must not be 403'd by diff --git a/internal/router/rbac.go b/internal/router/rbac.go index 67f24f5d..3820a114 100644 --- a/internal/router/rbac.go +++ b/internal/router/rbac.go @@ -231,6 +231,14 @@ func apiKeyFullAccess() middleware.APIKeyRoutePolicy { return middleware.APIKeyRoutePolicy{RequireFullAccess: true} } +func apiKeyPlatform(capabilities ...types.APIKeyCapability) middleware.APIKeyRoutePolicy { + policy := middleware.APIKeyRoutePolicy{PlatformOnly: true} + for _, capability := range capabilities { + policy = policy.WithCapability(capability) + } + return policy +} + // apiKeyRetrieve grants read/search access to knowledge-base data. func apiKeyRetrieve(base middleware.APIKeyRoutePolicy) middleware.APIKeyRoutePolicy { return base.WithCapability(types.APIKeyCapabilityRetrieve) diff --git a/internal/router/router.go b/internal/router/router.go index cb272fee..0de4bebb 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -654,10 +654,9 @@ func RegisterChatRoutes(r *gin.RouterGroup, handler *session.Handler, g *rbacGua // EnableCrossTenantAccess flag, replacing the 12-line if-block that // previously opened ListAllTenants and SearchTenants. // -// POST /tenants and GET /tenants stay open to authenticated users — -// the previous handler comments claimed CanAccessAllTenants gating -// "is in the handler" but the bodies never enforced it; this PR is a -// pure refactor and does not introduce new gates. +// JWT behavior for POST /tenants and GET /tenants remains unchanged. Platform +// API keys may create tenants through system_tenants_manage; workspace keys +// remain default-denied on tenant-catalog operations. func RegisterTenantRoutes( r *gin.RouterGroup, handler *handler.TenantHandler, @@ -668,8 +667,12 @@ func RegisterTenantRoutes( ) { // Cross-tenant superuser endpoints — promoted from handler if-blocks // to middleware.RequireCrossTenantAccess at the route layer. - r.GET("/tenants/all", g.CrossTenant(), handler.ListAllTenants) - r.GET("/tenants/search", g.CrossTenant(), handler.SearchTenants) + g.apiKeyRoute(r, http.MethodGet, "/tenants/all", + apiKeyPlatform(types.APIKeyCapabilitySystemTenantsRead, types.APIKeyCapabilitySystemTenantsManage), + g.CrossTenant(), handler.ListAllTenants) + g.apiKeyRoute(r, http.MethodGet, "/tenants/search", + apiKeyPlatform(types.APIKeyCapabilitySystemTenantsRead, types.APIKeyCapabilitySystemTenantsManage), + g.CrossTenant(), handler.SearchTenants) // 空间路由组 tenantRoutes := r.Group("/tenants") @@ -682,7 +685,8 @@ func RegisterTenantRoutes( // 不需要跨空间特权;handler 也不读写 X-Tenant-ID 指向的现有 // 空间,所以越过 PathTenantMatch 守卫不会扩大攻击面。 // 创建空间不对 API key 开放(注册在原始 group,默认拒绝)。 - tenantRoutes.POST("", handler.CreateTenant) + g.apiKeyRoute(tenantRoutes, http.MethodPost, "", + apiKeyPlatform(types.APIKeyCapabilitySystemTenantsManage), handler.CreateTenant) g.apiKeyRoute(tenantRoutes, http.MethodGet, "", apiKeyManageTenantSettings(apiKeyFullAccess()), handler.ListTenants) // Generic KV configuration management (tenant-level). Tenant ID @@ -700,9 +704,13 @@ func RegisterTenantRoutes( // opts in below through the manage_members capability. tenantByID := tenantRoutes.Group("/:id", g.PathTenantMatch()) { - tenantByID.GET("", g.Viewer(), handler.GetTenant) - tenantByID.PUT("", g.Owner(), handler.UpdateTenant) - tenantByID.DELETE("", g.Owner(), handler.DeleteTenant) + g.apiKeyRoute(tenantByID, http.MethodGet, "", + apiKeyPlatform(types.APIKeyCapabilitySystemTenantsRead, types.APIKeyCapabilitySystemTenantsManage), + g.Viewer(), handler.GetTenant) + g.apiKeyRoute(tenantByID, http.MethodPut, "", + apiKeyPlatform(types.APIKeyCapabilitySystemTenantsManage), g.Owner(), handler.UpdateTenant) + g.apiKeyRoute(tenantByID, http.MethodDelete, "", + apiKeyPlatform(types.APIKeyCapabilitySystemTenantsManage), g.Owner(), handler.DeleteTenant) tenantByID.GET("/api-keys", g.Owner(), handler.ListAPIKeys) tenantByID.POST("/api-keys", g.Owner(), handler.CreateAPIKey) tenantByID.DELETE("/api-keys/:key_id", g.Owner(), handler.DeleteAPIKey) @@ -948,30 +956,43 @@ func RegisterSystemAdminRoutes( adminRoutes.POST("/revoke", handler.RevokeSystemAdmin) adminRoutes.GET("/list", handler.ListSystemAdmins) adminRoutes.POST("/users/reset-password", handler.ResetUserPassword) + adminRoutes.GET("/api-keys", handler.ListPlatformAPIKeys) + adminRoutes.POST("/api-keys", handler.CreatePlatformAPIKey) + adminRoutes.DELETE("/api-keys/:key_id", handler.DeletePlatformAPIKey) // P1: platform-wide system settings (DB-backed runtime tunables). // Reads return raw model rows / arrays (no `gin.H{"data":...}` // wrapping), matching the project's axios interceptor convention // — see frontend/src/utils/request.ts:97. - adminRoutes.GET("/settings", handler.ListSystemSettings) - adminRoutes.GET("/settings/:key", handler.GetSystemSetting) - adminRoutes.PUT("/settings/:key", handler.UpdateSystemSetting) - adminRoutes.DELETE("/settings/:key", handler.ResetSystemSetting) + g.apiKeyRoute(adminRoutes, http.MethodGet, "/settings", + apiKeyPlatform(types.APIKeyCapabilitySystemSettingsRead, types.APIKeyCapabilitySystemSettingsManage), + handler.ListSystemSettings) + g.apiKeyRoute(adminRoutes, http.MethodGet, "/settings/:key", + apiKeyPlatform(types.APIKeyCapabilitySystemSettingsRead, types.APIKeyCapabilitySystemSettingsManage), + handler.GetSystemSetting) + g.apiKeyRoute(adminRoutes, http.MethodPut, "/settings/:key", + apiKeyPlatform(types.APIKeyCapabilitySystemSettingsManage), handler.UpdateSystemSetting) + g.apiKeyRoute(adminRoutes, http.MethodDelete, "/settings/:key", + apiKeyPlatform(types.APIKeyCapabilitySystemSettingsManage), handler.ResetSystemSetting) // Runtime operations: live asynq queue depths, safe task projections, // and state-checked task actions for the SystemAdmin dashboard. Lite // mode returns available=false. - adminRoutes.GET("/runtime/queues", handler.GetRuntimeQueues) - adminRoutes.GET("/runtime/queues/:queue/tasks", handler.ListRuntimeTasks) - adminRoutes.POST("/runtime/queues/:queue/tasks/:task_id/actions/:action", handler.MutateRuntimeTask) + g.apiKeyRoute(adminRoutes, http.MethodGet, "/runtime/queues", + apiKeyPlatform(types.APIKeyCapabilitySystemRuntimeRead, types.APIKeyCapabilitySystemRuntimeManage), + handler.GetRuntimeQueues) + g.apiKeyRoute(adminRoutes, http.MethodGet, "/runtime/queues/:queue/tasks", + apiKeyPlatform(types.APIKeyCapabilitySystemRuntimeRead, types.APIKeyCapabilitySystemRuntimeManage), + handler.ListRuntimeTasks) + g.apiKeyRoute(adminRoutes, http.MethodPost, "/runtime/queues/:queue/tasks/:task_id/actions/:action", + apiKeyPlatform(types.APIKeyCapabilitySystemRuntimeManage), handler.MutateRuntimeTask) // Bulk action — write the current default-quota setting onto // every existing tenant. Lives under /tenants instead of // /settings because it changes tenants, not the setting row. - adminRoutes.POST( - "/tenants/apply-default-storage-quota", - handler.ApplyDefaultStorageQuotaToAllTenants, - ) + g.apiKeyRoute(adminRoutes, http.MethodPost, "/tenants/apply-default-storage-quota", + apiKeyPlatform(types.APIKeyCapabilitySystemTenantsManage), + handler.ApplyDefaultStorageQuotaToAllTenants) // Platform-wide audit feed (tenant_id=0 rows). Covers // system.setting_changed / system.admin_promoted / @@ -981,7 +1002,8 @@ func RegisterSystemAdminRoutes( // out by tenant_id). Optional: skip when audit deps are // absent, matching RegisterTenantRoutes' /audit-log handling. if auditLogHandler != nil { - adminRoutes.GET("/audit-log", auditLogHandler.ListSystemAuditLog) + g.apiKeyRoute(adminRoutes, http.MethodGet, "/audit-log", + apiKeyPlatform(types.APIKeyCapabilitySystemAuditRead), auditLogHandler.ListSystemAuditLog) } } } diff --git a/internal/router/router_api_key_capabilities_test.go b/internal/router/router_api_key_capabilities_test.go index 0d3e0ae2..5bdaeb4a 100644 --- a/internal/router/router_api_key_capabilities_test.go +++ b/internal/router/router_api_key_capabilities_test.go @@ -47,6 +47,72 @@ func TestConversationRoutesDeclareChatCapability(t *testing.T) { } } +func TestPlatformControlPlaneRoutesDeclarePlatformCapabilities(t *testing.T) { + gin.SetMode(gin.TestMode) + g := &rbacGuards{} + v1 := gin.New().Group("/api/v1") + RegisterSystemAdminRoutes(v1, &handler.SystemHandler{}, nil, g) + + cases := []struct { + method string + path string + capability types.APIKeyCapability + }{ + {http.MethodGet, "/api/v1/system/admin/settings", types.APIKeyCapabilitySystemSettingsRead}, + {http.MethodPut, "/api/v1/system/admin/settings/:key", types.APIKeyCapabilitySystemSettingsManage}, + {http.MethodGet, "/api/v1/system/admin/runtime/queues", types.APIKeyCapabilitySystemRuntimeRead}, + {http.MethodPost, "/api/v1/system/admin/runtime/queues/:queue/tasks/:task_id/actions/:action", types.APIKeyCapabilitySystemRuntimeManage}, + } + for _, tc := range cases { + policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path) + if !policy.PlatformOnly { + t.Fatalf("%s %s must be platform-only", tc.method, tc.path) + } + if !policyHasCapability(policy, tc.capability) { + t.Fatalf("%s %s capabilities = %#v, want %s", tc.method, tc.path, policy.Capabilities, tc.capability) + } + } + if _, ok := g.apiKeyAuthorizer.Lookup(http.MethodPost, "/api/v1/system/admin/api-keys"); ok { + t.Fatal("platform API keys must not create other platform API keys") + } +} + +func TestPlatformTenantLifecycleRoutesDeclarePlatformCapabilities(t *testing.T) { + gin.SetMode(gin.TestMode) + g := &rbacGuards{} + v1 := gin.New().Group("/api/v1") + RegisterTenantRoutes( + v1, + &handler.TenantHandler{}, + &handler.TenantMemberHandler{}, + &handler.TenantInvitationHandler{}, + nil, + g, + ) + + cases := []struct { + method string + path string + capability types.APIKeyCapability + }{ + {http.MethodGet, "/api/v1/tenants/all", types.APIKeyCapabilitySystemTenantsRead}, + {http.MethodGet, "/api/v1/tenants/search", types.APIKeyCapabilitySystemTenantsRead}, + {http.MethodPost, "/api/v1/tenants", types.APIKeyCapabilitySystemTenantsManage}, + {http.MethodGet, "/api/v1/tenants/:id", types.APIKeyCapabilitySystemTenantsRead}, + {http.MethodPut, "/api/v1/tenants/:id", types.APIKeyCapabilitySystemTenantsManage}, + {http.MethodDelete, "/api/v1/tenants/:id", types.APIKeyCapabilitySystemTenantsManage}, + } + for _, tc := range cases { + policy := mustLookupAPIKeyPolicy(t, g, tc.method, tc.path) + if !policy.PlatformOnly { + t.Fatalf("%s %s must be platform-only", tc.method, tc.path) + } + if !policyHasCapability(policy, tc.capability) { + t.Fatalf("%s %s capabilities = %#v, want %s", tc.method, tc.path, policy.Capabilities, tc.capability) + } + } +} + func TestMessageHistoryRoutesDeclareMessageHistoryCapability(t *testing.T) { gin.SetMode(gin.TestMode) g := &rbacGuards{} diff --git a/internal/types/audit_log.go b/internal/types/audit_log.go index 69adb4a8..7ffc89c3 100644 --- a/internal/types/audit_log.go +++ b/internal/types/audit_log.go @@ -121,6 +121,8 @@ const ( // another user's local password. Details identify the target and record // session revocation, but never contain the old or new password. AuditActionSystemUserPasswordReset AuditAction = "system.user_password_reset" + AuditActionSystemAPIKeyCreated AuditAction = "system.api_key_created" + AuditActionSystemAPIKeyRevoked AuditAction = "system.api_key_revoked" // Runtime queue mutations are privileged SystemAdmin actions. Retrying an // archived task can repeat its original side effects; deleting one removes diff --git a/internal/types/interfaces/tenant.go b/internal/types/interfaces/tenant.go index 6bfa698e..15e6d2b7 100644 --- a/internal/types/interfaces/tenant.go +++ b/internal/types/interfaces/tenant.go @@ -64,6 +64,7 @@ type TenantRepository interface { type TenantAPIKeyCreateRequest struct { TenantID uint64 + ScopeType types.APIKeyScopeType Name string FullAccess bool KnowledgeBaseIDs []string @@ -80,7 +81,9 @@ type TenantAPIKeyRepository interface { CreateAPIKey(ctx context.Context, key *types.TenantAPIKey) error GetAPIKeyByHash(ctx context.Context, hash string) (*types.TenantAPIKey, error) ListAPIKeys(ctx context.Context, tenantID uint64) ([]*types.TenantAPIKey, error) + ListPlatformAPIKeys(ctx context.Context) ([]*types.TenantAPIKey, error) RevokeAPIKey(ctx context.Context, tenantID uint64, id uint64) error + RevokePlatformAPIKey(ctx context.Context, id uint64) error UpdateAPIKeyHash(ctx context.Context, id uint64, hash string) error UpdateAPIKeyLastUsed(ctx context.Context, id uint64, at time.Time) error // ListKeysWithPlaceholderHash returns keys whose key_hash is still the @@ -96,7 +99,9 @@ type TenantAPIKeyService interface { CreateAPIKey(ctx context.Context, req TenantAPIKeyCreateRequest) (*TenantAPIKeyCreateResult, error) AuthenticateAPIKey(ctx context.Context, token string) (*types.TenantAPIKey, error) ListAPIKeys(ctx context.Context, tenantID uint64) ([]*types.TenantAPIKey, error) + ListPlatformAPIKeys(ctx context.Context) ([]*types.TenantAPIKey, error) RevokeAPIKey(ctx context.Context, tenantID uint64, id uint64) error + RevokePlatformAPIKey(ctx context.Context, id uint64) error // BackfillMissingKeyHashes computes and persists the SHA-256 key_hash // for legacy keys still carrying the migration placeholder. // Returns the number of keys backfilled. diff --git a/internal/types/principal.go b/internal/types/principal.go index 60cf4552..e3cb9586 100644 --- a/internal/types/principal.go +++ b/internal/types/principal.go @@ -9,6 +9,7 @@ import ( const ( PrincipalWebUser = "web_user" PrincipalAPITenant = "api_tenant" + PrincipalAPIPlatform = "api_platform" PrincipalAPIExternalUser = "api_external_user" PrincipalIMUser = "im_user" PrincipalEmbedChannel = "embed_channel" diff --git a/internal/types/tenant_api_key.go b/internal/types/tenant_api_key.go index 2c2ec513..b3e68762 100644 --- a/internal/types/tenant_api_key.go +++ b/internal/types/tenant_api_key.go @@ -11,17 +11,19 @@ import ( "gorm.io/gorm" ) -// TenantAPIKey is a revocable, per-tenant API key. KeyHash is used for -// authentication lookup; APIKey is stored encrypted when SYSTEM_AES_KEY is set -// and returned by owner-only management APIs. +// TenantAPIKey is a revocable machine credential. Tenant-scoped and platform +// keys intentionally share one table; platform keys have tenant_id=NULL and +// choose a target workspace per request. KeyHash is used for authentication +// lookup; APIKey is stored encrypted when SYSTEM_AES_KEY is set. type TenantAPIKey struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - TenantID uint64 `json:"tenant_id" gorm:"not null;index"` - Name string `json:"name" gorm:"type:varchar(128);not null"` - KeyHash string `json:"-" gorm:"type:varchar(64);not null;uniqueIndex"` - APIKey string `json:"api_key" gorm:"column:api_key;type:text;not null;default:''"` - FullAccess bool `json:"full_access" gorm:"not null;default:false"` - KnowledgeBaseIDs StringArray `json:"knowledge_base_ids" gorm:"type:jsonb;not null;default:'[]'"` + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + TenantID *uint64 `json:"tenant_id,omitempty" gorm:"index"` + ScopeType APIKeyScopeType `json:"scope_type" gorm:"type:varchar(16);not null;default:tenant;index"` + Name string `json:"name" gorm:"type:varchar(128);not null"` + KeyHash string `json:"-" gorm:"type:varchar(64);not null;uniqueIndex"` + APIKey string `json:"api_key" gorm:"column:api_key;type:text;not null;default:''"` + FullAccess bool `json:"full_access" gorm:"not null;default:false"` + KnowledgeBaseIDs StringArray `json:"knowledge_base_ids" gorm:"type:jsonb;not null;default:'[]'"` // Capabilities are bounded grants for non-full-access keys. Each // capability maps to an integration persona (retrieval, chat, ingest, // tenant infrastructure management, and history access). KB scoping @@ -35,6 +37,33 @@ type TenantAPIKey struct { UpdatedAt time.Time `json:"updated_at"` } +type APIKeyScopeType string + +const ( + APIKeyScopeTenant APIKeyScopeType = "tenant" + APIKeyScopePlatform APIKeyScopeType = "platform" +) + +func NormalizeAPIKeyScopeType(scope APIKeyScopeType) APIKeyScopeType { + switch APIKeyScopeType(strings.ToLower(strings.TrimSpace(string(scope)))) { + case APIKeyScopePlatform: + return APIKeyScopePlatform + default: + return APIKeyScopeTenant + } +} + +func (k *TenantAPIKey) IsPlatform() bool { + return k != nil && NormalizeAPIKeyScopeType(k.ScopeType) == APIKeyScopePlatform +} + +func (k *TenantAPIKey) TenantIDValue() uint64 { + if k == nil || k.TenantID == nil { + return 0 + } + return *k.TenantID +} + func (TenantAPIKey) TableName() string { return "tenant_api_keys" } @@ -126,6 +155,13 @@ const ( // principal mode, request headers, and tenant KV. It does not include API // key management, member management, tenant deletion, or ownership transfer. APIKeyCapabilityManageTenantSettings APIKeyCapability = "manage_tenant_settings" + APIKeyCapabilitySystemTenantsRead APIKeyCapability = "system_tenants_read" + APIKeyCapabilitySystemTenantsManage APIKeyCapability = "system_tenants_manage" + APIKeyCapabilitySystemSettingsRead APIKeyCapability = "system_settings_read" + APIKeyCapabilitySystemSettingsManage APIKeyCapability = "system_settings_manage" + APIKeyCapabilitySystemRuntimeRead APIKeyCapability = "system_runtime_read" + APIKeyCapabilitySystemRuntimeManage APIKeyCapability = "system_runtime_manage" + APIKeyCapabilitySystemAuditRead APIKeyCapability = "system_audit_read" ) // NormalizeAPIKeyCapability maps an input capability string to a known @@ -168,6 +204,20 @@ func NormalizeAPIKeyCapability(c APIKeyCapability) APIKeyCapability { return APIKeyCapabilityManageSpaces case APIKeyCapabilityManageTenantSettings: return APIKeyCapabilityManageTenantSettings + case APIKeyCapabilitySystemTenantsRead: + return APIKeyCapabilitySystemTenantsRead + case APIKeyCapabilitySystemTenantsManage: + return APIKeyCapabilitySystemTenantsManage + case APIKeyCapabilitySystemSettingsRead: + return APIKeyCapabilitySystemSettingsRead + case APIKeyCapabilitySystemSettingsManage: + return APIKeyCapabilitySystemSettingsManage + case APIKeyCapabilitySystemRuntimeRead: + return APIKeyCapabilitySystemRuntimeRead + case APIKeyCapabilitySystemRuntimeManage: + return APIKeyCapabilitySystemRuntimeManage + case APIKeyCapabilitySystemAuditRead: + return APIKeyCapabilitySystemAuditRead default: return "" } @@ -218,6 +268,7 @@ func (k *TenantAPIKey) AfterFind(tx *gorm.DB) error { // TenantAPIKeyScope is the request-context projection used by middleware. type TenantAPIKeyScope struct { KeyID uint64 + ScopeType APIKeyScopeType FullAccess bool KnowledgeBaseIDs StringArray Capabilities StringArray @@ -241,12 +292,17 @@ func TenantAPIKeyScopeFromContext(ctx context.Context) (TenantAPIKeyScope, bool) func (s TenantAPIKeyScope) Normalize() TenantAPIKeyScope { return TenantAPIKeyScope{ KeyID: s.KeyID, + ScopeType: NormalizeAPIKeyScopeType(s.ScopeType), FullAccess: s.FullAccess, KnowledgeBaseIDs: normalizeIDArray(s.KnowledgeBaseIDs), Capabilities: NormalizeAPIKeyCapabilities(s.Capabilities), } } +func (s TenantAPIKeyScope) IsPlatform() bool { + return NormalizeAPIKeyScopeType(s.ScopeType) == APIKeyScopePlatform +} + // HasCapability reports whether the scope carries the given additive grant. func (s TenantAPIKeyScope) HasCapability(c APIKeyCapability) bool { c = NormalizeAPIKeyCapability(c) diff --git a/migrations/sqlite/000071_platform_api_keys.down.sql b/migrations/sqlite/000071_platform_api_keys.down.sql new file mode 100644 index 00000000..b9fda0fd --- /dev/null +++ b/migrations/sqlite/000071_platform_api_keys.down.sql @@ -0,0 +1,40 @@ +DELETE FROM tenant_api_keys WHERE scope_type = 'platform'; + +DROP INDEX IF EXISTS idx_tenant_api_keys_tenant; +DROP INDEX IF EXISTS idx_tenant_api_keys_revoked_at; +DROP INDEX IF EXISTS idx_tenant_api_keys_scope_type; + +ALTER TABLE tenant_api_keys RENAME TO tenant_api_keys_platform_scope; + +CREATE TABLE tenant_api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + api_key TEXT NOT NULL DEFAULT '', + full_access BOOLEAN NOT NULL DEFAULT 0, + knowledge_base_ids TEXT NOT NULL DEFAULT '[]', + capabilities TEXT NOT NULL DEFAULT '[]', + last_used_at DATETIME, + expires_at DATETIME, + revoked_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +); + +INSERT INTO tenant_api_keys ( + id, tenant_id, name, key_hash, api_key, full_access, + knowledge_base_ids, capabilities, last_used_at, expires_at, revoked_at, + created_at, updated_at +) +SELECT + id, tenant_id, name, key_hash, api_key, full_access, + knowledge_base_ids, capabilities, last_used_at, expires_at, revoked_at, + created_at, updated_at +FROM tenant_api_keys_platform_scope; + +DROP TABLE tenant_api_keys_platform_scope; + +CREATE INDEX idx_tenant_api_keys_tenant ON tenant_api_keys(tenant_id); +CREATE INDEX idx_tenant_api_keys_revoked_at ON tenant_api_keys(revoked_at); diff --git a/migrations/sqlite/000071_platform_api_keys.up.sql b/migrations/sqlite/000071_platform_api_keys.up.sql new file mode 100644 index 00000000..6eaa6035 --- /dev/null +++ b/migrations/sqlite/000071_platform_api_keys.up.sql @@ -0,0 +1,44 @@ +DROP INDEX IF EXISTS idx_tenant_api_keys_tenant; +DROP INDEX IF EXISTS idx_tenant_api_keys_revoked_at; + +ALTER TABLE tenant_api_keys RENAME TO tenant_api_keys_legacy; + +CREATE TABLE tenant_api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER, + scope_type TEXT NOT NULL DEFAULT 'tenant' + CHECK (scope_type IN ('tenant', 'platform')), + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + api_key TEXT NOT NULL DEFAULT '', + full_access BOOLEAN NOT NULL DEFAULT 0, + knowledge_base_ids TEXT NOT NULL DEFAULT '[]', + capabilities TEXT NOT NULL DEFAULT '[]', + last_used_at DATETIME, + expires_at DATETIME, + revoked_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CHECK ( + (scope_type = 'tenant' AND tenant_id IS NOT NULL) + OR (scope_type = 'platform' AND tenant_id IS NULL AND full_access = 0) + ) +); + +INSERT INTO tenant_api_keys ( + id, tenant_id, scope_type, name, key_hash, api_key, full_access, + knowledge_base_ids, capabilities, last_used_at, expires_at, revoked_at, + created_at, updated_at +) +SELECT + id, tenant_id, 'tenant', name, key_hash, api_key, full_access, + knowledge_base_ids, capabilities, last_used_at, expires_at, revoked_at, + created_at, updated_at +FROM tenant_api_keys_legacy; + +DROP TABLE tenant_api_keys_legacy; + +CREATE INDEX idx_tenant_api_keys_tenant ON tenant_api_keys(tenant_id); +CREATE INDEX idx_tenant_api_keys_revoked_at ON tenant_api_keys(revoked_at); +CREATE INDEX idx_tenant_api_keys_scope_type ON tenant_api_keys(scope_type); diff --git a/migrations/versioned/000071_platform_api_keys.down.sql b/migrations/versioned/000071_platform_api_keys.down.sql new file mode 100644 index 00000000..dffa1560 --- /dev/null +++ b/migrations/versioned/000071_platform_api_keys.down.sql @@ -0,0 +1,12 @@ +DELETE FROM tenant_api_keys WHERE scope_type = 'platform'; + +DROP INDEX IF EXISTS idx_tenant_api_keys_scope_type; + +ALTER TABLE tenant_api_keys + DROP CONSTRAINT IF EXISTS chk_tenant_api_keys_scope; + +ALTER TABLE tenant_api_keys + ALTER COLUMN tenant_id SET NOT NULL; + +ALTER TABLE tenant_api_keys + DROP COLUMN IF EXISTS scope_type; diff --git a/migrations/versioned/000071_platform_api_keys.up.sql b/migrations/versioned/000071_platform_api_keys.up.sql new file mode 100644 index 00000000..a7ea3e54 --- /dev/null +++ b/migrations/versioned/000071_platform_api_keys.up.sql @@ -0,0 +1,21 @@ +DO $$ BEGIN RAISE NOTICE '[Migration 000071] Adding platform API key scope...'; END $$; + +ALTER TABLE tenant_api_keys + ADD COLUMN IF NOT EXISTS scope_type VARCHAR(16) NOT NULL DEFAULT 'tenant'; + +ALTER TABLE tenant_api_keys + ALTER COLUMN tenant_id DROP NOT NULL; + +ALTER TABLE tenant_api_keys + DROP CONSTRAINT IF EXISTS chk_tenant_api_keys_scope; + +ALTER TABLE tenant_api_keys + ADD CONSTRAINT chk_tenant_api_keys_scope CHECK ( + (scope_type = 'tenant' AND tenant_id IS NOT NULL) + OR (scope_type = 'platform' AND tenant_id IS NULL AND full_access = FALSE) + ); + +CREATE INDEX IF NOT EXISTS idx_tenant_api_keys_scope_type + ON tenant_api_keys(scope_type); + +DO $$ BEGIN RAISE NOTICE '[Migration 000071] Platform API key scope ready'; END $$;