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.
This commit is contained in:
wizardchen
2026-07-21 15:05:57 +08:00
committed by lyingbug
parent c695125dce
commit 23db592c4a
42 changed files with 2967 additions and 1083 deletions
+1 -1
View File
@@ -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 (
+4
View File
@@ -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`),不同知识库绑定到不同实例,空间维度还有一个默认实例:
+31 -2
View File
@@ -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: <platform-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:隔离边界与安全说明
+126 -2
View File
@@ -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"
+126 -2
View File
@@ -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"
+89 -2
View File
@@ -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
+24
View File
@@ -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
+8
View File
@@ -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
+100
View File
@@ -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<TenantAPIKeyCapability>([
'retrieve', 'chat', 'read_agents',
])
export const KB_SCOPED_API_KEY_CAPABILITIES = new Set<TenantAPIKeyCapability>([
'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,
]
+17
View File
@@ -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',
},
}
+12
View File
@@ -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 키를 만들지 못했습니다",
},
};
+12
View File
@@ -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: 'Не удалось создать ключ',
},
}
+18
View File
@@ -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 失败",
},
};
@@ -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<TenantAPIKeyCapability>(['retrieve', 'chat', 'read_agents'])
const KB_SCOPED_CAPABILITIES = new Set<TenantAPIKeyCapability>([
'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<Record<TenantAPIKeyCapability, boolean>>(
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<TenantAPIKeyCapability, string> = {
const labels: Partial<Record<TenantAPIKeyCapability, string>> = {
retrieve: t('integrations.api.capabilityRetrieve'),
chat: t('integrations.api.capabilityChat'),
read_agents: t('integrations.api.capabilityReadAgents'),
+15 -3
View File
@@ -144,6 +144,14 @@
<RuntimeQueues />
</div>
<div v-if="currentSection === 'platform-api-keys'" class="section">
<PlatformAPIKeys />
</div>
<div v-if="currentSection === 'system-audit-log'" class="section">
<SystemAuditLog />
</div>
<!-- 用户信息(账户基础信息:ID / 用户名 / 邮箱 / 注册时间)。
用户的基本信息不该跟 owner 权限绑定。 -->
<div v-if="currentSection === 'userprofile'" class="section">
@@ -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<string, RoleKey> = {
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<NavGroup[]>(() => {
{
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()
@@ -0,0 +1,692 @@
<template>
<div class="platform-api-keys">
<header class="section-header">
<h2>{{ t('platformApiKeys.title') }}</h2>
<p class="section-description">{{ t('platformApiKeys.description') }}</p>
</header>
<t-alert theme="warning" :message="t('platformApiKeys.securityNotice')" class="security-alert">
<template #operation>
<t-button size="small" variant="outline" @click="openCreate">
<template #icon><t-icon name="add" /></template>
{{ t('platformApiKeys.create') }}
</t-button>
</template>
</t-alert>
<section class="keys-section">
<div v-if="loading" class="keys-state">
<t-loading size="small" />
<span>{{ t('platformApiKeys.loading') }}</span>
</div>
<div v-else-if="keys.length === 0" class="keys-state keys-state--empty">
<span>{{ t('platformApiKeys.empty') }}</span>
<t-button size="small" variant="outline" @click="openCreate">
<template #icon><t-icon name="add" /></template>
{{ t('platformApiKeys.create') }}
</t-button>
</div>
<div v-else class="api-key-table-wrap">
<table class="api-key-table">
<thead>
<tr>
<th>{{ t('platformApiKeys.name') }}</th>
<th>{{ t('platformApiKeys.key') }}</th>
<th>{{ t('platformApiKeys.capability') }}</th>
<th>{{ t('platformApiKeys.lastUsed') }}</th>
<th>{{ t('platformApiKeys.createdAt') }}</th>
<th class="api-key-table__actions-heading">{{ t('platformApiKeys.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="key in keys" :key="key.id">
<td>
<span class="api-key-name">{{ key.name }}</span>
</td>
<td>
<code class="api-key-fingerprint">{{ key.api_key }}</code>
</td>
<td class="api-key-table__capability-cell">
<div class="api-key-capability-inline">
<span
v-for="chip in visibleCapabilityChips(key)"
:key="chip.id"
class="api-key-capability-chip"
>
{{ chip.label }}
</span>
<t-popup
v-if="hiddenCapabilityCount(key) > 0"
trigger="click"
placement="bottom-left"
destroy-on-close
overlay-class-name="platform-api-key-capability-popup-overlay"
>
<button
type="button"
class="api-key-capability-chip api-key-capability-chip--more"
:aria-label="t('platformApiKeys.viewAllCapabilities')"
>
{{ t('platformApiKeys.capabilityMore', { count: hiddenCapabilityCount(key) }) }}
</button>
<template #content>
<div class="api-key-capability-popup">
<div class="api-key-capability-popup__title">{{ t('platformApiKeys.capability') }}</div>
<div
v-for="group in capabilityGroupsForKey(key)"
:key="group.key"
class="api-key-capability-block"
>
<div class="api-key-capability-block__title">{{ group.label }}</div>
<div class="api-key-capability-block__chips">
<span
v-for="label in group.labels"
:key="label"
class="api-key-capability-chip"
>
{{ label }}
</span>
</div>
</div>
</div>
</template>
</t-popup>
</div>
</td>
<td>
<span class="api-key-meta">{{ formatDate(key.last_used_at) }}</span>
</td>
<td>
<time class="api-key-date" :datetime="key.created_at">{{ formatDate(key.created_at) }}</time>
</td>
<td>
<div class="api-key-table__actions">
<t-popconfirm
:content="t('platformApiKeys.deleteConfirm', { name: key.name })"
:confirm-btn="{ content: t('common.delete'), theme: 'danger' }"
:cancel-btn="{ content: t('common.cancel') }"
placement="bottom-right"
@confirm="deleteKey(key)"
>
<t-button
shape="square"
variant="text"
theme="danger"
:title="t('common.delete')"
@click.stop
>
<t-icon name="delete" />
</t-button>
</t-popconfirm>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<SettingDrawer
:visible="drawerVisible"
class="api-key-create-drawer"
:title="t('platformApiKeys.create')"
:description="t('platformApiKeys.createDescription')"
icon="secured"
width="560px"
:min-width="480"
:max-width="920"
storage-key="setting-drawer:width:platform-api-key-create"
:close-on-overlay-click="false"
:confirm-text="t('platformApiKeys.create')"
:confirm-loading="creating"
@update:visible="drawerVisible = $event"
@confirm="createKey"
>
<div class="api-key-dialog">
<div class="api-key-dialog-row">
<div class="api-key-dialog-row__label">
<label>{{ t('platformApiKeys.name') }}</label>
</div>
<t-input v-model="form.name" :placeholder="t('platformApiKeys.namePlaceholder')" />
</div>
<div class="api-key-dialog-row">
<div class="api-key-dialog-row__label">
<label>{{ t('platformApiKeys.capability') }}</label>
</div>
<p class="scope-hint">{{ t('platformApiKeys.capabilityHint') }}</p>
<div class="api-key-capability-list">
<div
v-for="group in PLATFORM_API_KEY_CAPABILITY_GROUPS"
:key="group.key"
class="api-key-capability-group"
>
<div class="api-key-capability-group__header">
<span>{{ t(group.labelKey) }}</span>
<t-button
size="small"
variant="text"
@click="toggleGroup(group.capabilities.map(item => item.value))"
>
{{
groupSelected(group.capabilities.map(item => item.value))
? t('integrations.api.apiKeyCapabilityClearGroup')
: t('integrations.api.apiKeyCapabilitySelectGroup')
}}
</t-button>
</div>
<div class="api-key-capability-group__items">
<div
v-for="item in group.capabilities"
:key="item.value"
class="api-key-capability-item"
>
<t-checkbox v-model="selected[item.value]">{{ t(item.labelKey) }}</t-checkbox>
<p class="scope-hint">{{ t(item.hintKey) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</SettingDrawer>
<t-dialog
v-model:visible="tokenVisible"
:header="t('platformApiKeys.createdTitle')"
:confirm-btn="{ content: t('platformApiKeys.copy'), theme: 'primary' }"
:cancel-btn="null"
:close-on-overlay-click="false"
@confirm="copyToken"
>
<p>{{ t('platformApiKeys.createdDescription') }}</p>
<t-textarea :value="createdToken" readonly autosize />
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
import type { TenantAPIKey, TenantAPIKeyCapability } from '@/api/tenant'
import { createPlatformAPIKey, deletePlatformAPIKey, listPlatformAPIKeys } from '@/api/system'
import {
PLATFORM_API_KEY_CAPABILITY_GROUPS,
SYSTEM_API_KEY_CAPABILITIES,
TENANT_API_KEY_CAPABILITIES,
} from '@/config/apiKeyCapabilities'
const { t } = useI18n()
const allCapabilities = [...SYSTEM_API_KEY_CAPABILITIES, ...TENANT_API_KEY_CAPABILITIES]
const keys = ref<TenantAPIKey[]>([])
const loading = ref(false)
const creating = ref(false)
const drawerVisible = ref(false)
const tokenVisible = ref(false)
const createdToken = ref('')
const form = reactive({ name: '' })
const selected = reactive<Record<TenantAPIKeyCapability, boolean>>(
allCapabilities.reduce((result, capability) => {
result[capability] = false
return result
}, {} as Record<TenantAPIKeyCapability, boolean>),
)
const VISIBLE_CAPABILITY_CHIP_COUNT = 4
type CapabilityChipView = {
id: TenantAPIKeyCapability
label: string
}
function capabilityChipsForKey(key: TenantAPIKey): CapabilityChipView[] {
const chips: CapabilityChipView[] = []
for (const group of PLATFORM_API_KEY_CAPABILITY_GROUPS) {
for (const item of group.capabilities) {
if (key.capabilities?.includes(item.value)) {
chips.push({ id: item.value, label: t(item.labelKey) })
}
}
}
return chips
}
function visibleCapabilityChips(key: TenantAPIKey) {
return capabilityChipsForKey(key).slice(0, VISIBLE_CAPABILITY_CHIP_COUNT)
}
function hiddenCapabilityCount(key: TenantAPIKey) {
const total = capabilityChipsForKey(key).length
return Math.max(0, total - VISIBLE_CAPABILITY_CHIP_COUNT)
}
function capabilityGroupsForKey(key: TenantAPIKey) {
return PLATFORM_API_KEY_CAPABILITY_GROUPS
.map(group => {
const labels = group.capabilities
.filter(item => key.capabilities?.includes(item.value))
.map(item => t(item.labelKey))
if (labels.length === 0) return null
return { key: group.key, label: t(group.labelKey), labels }
})
.filter((group): group is { key: string; label: string; labels: string[] } => group !== null)
}
function formatDate(value?: string) {
if (!value) return t('platformApiKeys.never')
const date = new Date(value)
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function resetForm() {
form.name = ''
allCapabilities.forEach(capability => { selected[capability] = false })
}
function openCreate() {
resetForm()
drawerVisible.value = true
}
function groupSelected(capabilities: TenantAPIKeyCapability[]) {
return capabilities.every(capability => selected[capability])
}
function toggleGroup(capabilities: TenantAPIKeyCapability[]) {
const next = !groupSelected(capabilities)
capabilities.forEach(capability => { selected[capability] = next })
}
async function reload() {
loading.value = true
try {
const response = await listPlatformAPIKeys()
keys.value = response.data ?? []
} catch (error: any) {
MessagePlugin.error(error?.message || t('platformApiKeys.loadFailed'))
} finally {
loading.value = false
}
}
async function createKey() {
const capabilities = allCapabilities.filter(capability => selected[capability])
if (!form.name.trim()) {
MessagePlugin.warning(t('platformApiKeys.nameRequired'))
return
}
if (capabilities.length === 0) {
MessagePlugin.warning(t('platformApiKeys.capabilityRequired'))
return
}
creating.value = true
try {
const response = await createPlatformAPIKey({ name: form.name.trim(), capabilities })
createdToken.value = response.data?.token ?? ''
drawerVisible.value = false
tokenVisible.value = true
await reload()
} catch (error: any) {
MessagePlugin.error(error?.message || t('platformApiKeys.createFailed'))
} finally {
creating.value = false
}
}
async function deleteKey(key: TenantAPIKey) {
try {
await deletePlatformAPIKey(key.id)
MessagePlugin.success(t('platformApiKeys.deleteSuccess'))
await reload()
} catch (error: any) {
MessagePlugin.error(error?.message || t('platformApiKeys.deleteFailed'))
}
}
async function copyToken() {
await navigator.clipboard.writeText(createdToken.value)
MessagePlugin.success(t('platformApiKeys.copySuccess'))
tokenVisible.value = false
}
onMounted(reload)
</script>
<style scoped>
.platform-api-keys {
width: 100%;
}
.section-header {
margin-bottom: 20px;
}
.section-header h2 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.section-description {
margin: 0;
color: var(--td-text-color-secondary);
font-size: 14px;
line-height: 1.5;
}
.security-alert {
margin-bottom: 20px;
}
.keys-section {
border: 1px solid var(--td-component-border);
border-radius: 10px;
background: var(--td-bg-color-container);
overflow: hidden;
}
.keys-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 120px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
.keys-state--empty {
flex-direction: column;
gap: 12px;
}
.api-key-table-wrap {
width: 100%;
overflow-x: auto;
}
.api-key-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.api-key-table th,
.api-key-table td {
padding: 13px 14px;
border-bottom: 1px solid var(--td-component-stroke);
text-align: left;
vertical-align: middle;
}
.api-key-table th {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-placeholder);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.api-key-table td {
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 1.45;
}
.api-key-table th:nth-child(1),
.api-key-table td:nth-child(1) {
width: 11%;
}
.api-key-table th:nth-child(2),
.api-key-table td:nth-child(2) {
width: 17%;
}
.api-key-table th:nth-child(3),
.api-key-table td:nth-child(3) {
width: auto;
}
.api-key-table th:nth-child(4),
.api-key-table td:nth-child(4) {
width: 80px;
}
.api-key-table th:nth-child(5),
.api-key-table td:nth-child(5) {
width: 128px;
}
.api-key-table th:nth-child(6),
.api-key-table td:nth-child(6) {
width: 52px;
}
.api-key-table__capability-cell {
vertical-align: middle;
}
.api-key-capability-inline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
}
.api-key-capability-chip--more {
border: 1px dashed color-mix(in srgb, var(--td-success-color) 35%, transparent);
background: transparent;
cursor: pointer;
}
.api-key-capability-popup {
width: 320px;
max-width: min(360px, 88vw);
max-height: 360px;
overflow: auto;
padding: 12px 14px;
}
.api-key-capability-popup__title {
margin-bottom: 10px;
color: var(--td-text-color-primary);
font-size: 13px;
font-weight: 600;
line-height: 1.4;
}
.api-key-table tbody tr:last-child td {
border-bottom: none;
}
.api-key-table__actions-heading {
text-align: right !important;
}
.api-key-table__actions {
display: flex;
align-items: center;
justify-content: flex-end;
}
.api-key-capability-block + .api-key-capability-block {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--td-component-stroke);
}
.api-key-capability-block__title {
margin-bottom: 6px;
color: var(--td-text-color-primary);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
}
.api-key-capability-block__chips {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.api-key-capability-chip {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--td-success-color) 10%, var(--td-bg-color-container));
color: var(--td-success-color);
font-size: 12px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.api-key-name {
display: block;
min-width: 0;
color: var(--td-text-color-primary);
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-key-fingerprint {
display: inline-block;
max-width: 100%;
color: var(--td-text-color-secondary);
font-family: var(--app-font-family-mono);
font-size: 12px;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
white-space: nowrap;
}
.api-key-meta {
display: block;
min-width: 0;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
}
.api-key-date {
display: block;
font-family: var(--app-font-family-mono);
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
color: var(--td-text-color-secondary);
}
.api-key-dialog {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
border-bottom: 1px solid var(--td-component-stroke);
}
.api-key-dialog-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 0 16px;
border-bottom: 1px solid var(--td-component-stroke);
}
.api-key-dialog-row:first-child {
padding-top: 0;
}
.api-key-dialog-row:last-child {
border-bottom: none;
}
.api-key-dialog-row__label label {
display: flex;
align-items: center;
gap: 8px;
color: var(--td-text-color-primary);
font-size: 14px;
font-weight: 600;
line-height: 1.45;
}
.api-key-dialog-row__label label::before {
content: '';
flex-shrink: 0;
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--td-brand-color);
}
.api-key-dialog-row :deep(.t-input) {
border-radius: 4px;
background-color: var(--td-bg-color-secondarycontainer);
border-color: transparent;
box-shadow: none !important;
}
.api-key-dialog-row :deep(.t-input:hover),
.api-key-dialog-row :deep(.t-input.t-is-focused) {
border-color: var(--td-component-border);
background-color: var(--td-bg-color-container);
}
.scope-hint {
margin: 0;
color: var(--td-text-color-placeholder);
font-size: 12px;
line-height: 18px;
}
.api-key-capability-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.api-key-capability-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 0 2px;
}
.api-key-capability-group + .api-key-capability-group {
border-top: 1px solid var(--td-component-stroke);
}
.api-key-capability-group__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
color: var(--td-text-color-primary);
font-size: 13px;
font-weight: 600;
}
.api-key-capability-group__items {
display: flex;
flex-direction: column;
gap: 10px;
}
.api-key-capability-item .scope-hint {
margin: 2px 0 0 24px;
}
</style>
+59 -8
View File
@@ -15,16 +15,19 @@
:aria-label="t('system.globalSettings.runtime.autoRefresh')"
/>
</label>
<t-button
variant="outline"
size="small"
:loading="loading"
<button
type="button"
class="rq-refresh"
:disabled="loading"
:title="t('system.globalSettings.runtime.refresh')"
:aria-label="t('system.globalSettings.runtime.refresh')"
@click="reload"
>
<template #icon><t-icon name="refresh" /></template>
{{ t('system.globalSettings.runtime.refresh') }}
</t-button>
<t-icon
:name="loading ? 'loading' : 'refresh'"
:class="{ 'rq-refresh-spin': loading }"
/>
</button>
</div>
</header>
@@ -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;
@@ -0,0 +1,755 @@
<template>
<div class="system-audit-log">
<header class="section-header audit-page-header">
<div class="audit-page-header__title">
<h2>{{ t('system.globalSettings.audit.tabLabel') }}</h2>
<p class="section-description">{{ t('system.globalSettings.audit.description') }}</p>
</div>
<button
type="button"
class="rq-refresh"
:disabled="auditLoading"
:title="t('system.globalSettings.audit.refresh')"
:aria-label="t('system.globalSettings.audit.refresh')"
@click="reloadAuditLog"
>
<t-icon
:name="auditLoading ? 'loading' : 'refresh'"
:class="{ 'rq-refresh-spin': auditLoading }"
/>
</button>
</header>
<div class="audit-page-body">
<div v-if="auditError" class="audit-page-branch audit-page-branch--error">
<t-alert theme="error" :message="auditError">
<template #operation>
<t-button size="small" @click="reloadAuditLog">
{{ t('system.globalSettings.audit.retry') }}
</t-button>
</template>
</t-alert>
</div>
<div
v-else-if="!auditLoading && auditEntries.length === 0"
class="audit-page-branch audit-page-branch--empty"
>
<t-empty :description="t('system.globalSettings.audit.empty')" />
</div>
<div v-else class="audit-scroll-area narrow-scrollbar audit-page-branch" ref="auditScrollRoot">
<div class="data-table-shell audit-table-shell">
<t-table
row-key="id"
:data="auditEntries"
:columns="auditColumns"
size="medium"
hover
expand-on-row-click
:expanded-row-keys="auditExpandedRowKeys"
@expand-change="onAuditExpandChange"
>
<template #created_at="{ row }">
<div class="audit-time">
<span class="audit-time-date">{{ formatAuditDatePart(row.created_at) }}</span>
<span class="audit-time-clock">{{ formatAuditTimePart(row.created_at) }}</span>
</div>
</template>
<template #actor="{ row }">
<div class="audit-actor">
<span class="audit-actor-name">
{{ row.actor_user_id ? auditActorLabel(row.actor_user_id) :
t('system.globalSettings.audit.systemActor') }}
</span>
<span v-if="row.actor_role" class="audit-actor-role">
{{ auditActorRoleLabel(row.actor_role) }}
</span>
</div>
</template>
<template #action="{ row }">
<t-tag :theme="auditActionTheme(row.action)" size="small" variant="light-outline">
{{ formatAuditAction(row.action) }}
</t-tag>
</template>
<template #target="{ row }">
<div class="audit-target">
<span v-if="auditTargetKey(row)" class="audit-target-key">{{ auditTargetKey(row) }}</span>
<span v-if="auditTargetDiff(row)" class="audit-target-diff">{{ auditTargetDiff(row) }}</span>
<span v-else-if="!auditTargetKey(row)" class="audit-target-empty">—</span>
</div>
</template>
<template #outcome="{ row }">
<t-tag :theme="auditOutcomeTheme(row.outcome)" size="small" variant="light">
{{ t('system.globalSettings.audit.outcome.' + row.outcome) }}
</t-tag>
</template>
<template #expandedRow="{ row }">
<div class="audit-expanded">
<div class="audit-expanded-grid">
<div class="audit-expanded-cell">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.actorId') }}</span>
<span class="audit-expanded-value mono">{{ row.actor_user_id || '—' }}</span>
</div>
<div v-if="row.target_user_id" class="audit-expanded-cell">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.targetUserId') }}</span>
<span class="audit-expanded-value mono">{{ row.target_user_id }}</span>
</div>
<div v-if="row.target_type" class="audit-expanded-cell">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.targetType') }}</span>
<span class="audit-expanded-value mono">{{ row.target_type }}</span>
</div>
<div v-if="row.target_id" class="audit-expanded-cell">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.targetId') }}</span>
<span class="audit-expanded-value mono">{{ row.target_id }}</span>
</div>
</div>
<div class="audit-expanded-details">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.details') }}</span>
<pre class="audit-expanded-json mono">{{ auditDetailsJSON(row) }}</pre>
</div>
</div>
</template>
</t-table>
</div>
<div ref="auditLoadSentinelEl" class="audit-load-sentinel" aria-hidden="true" />
<div v-if="auditLoading && auditEntries.length > 0" class="audit-loading-more">
<t-loading size="small" />
<span>{{ t('system.globalSettings.audit.loading') }}</span>
</div>
<p v-if="!auditHasMore && auditEntries.length > 0 && !auditLoading" class="audit-end-hint">
{{ t('system.globalSettings.audit.end') }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
listSystemAuditLog,
type AuditAction,
type AuditLog,
type AuditOutcome,
} from '@/api/system'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const { t, tm, te, locale } = useI18n()
const auditEntries = ref<AuditLog[]>([])
const auditLoading = ref(false)
const auditError = ref('')
const auditCursor = ref<number>(0)
const auditHasMore = ref(true)
const AUDIT_PAGE_SIZE = 50
const auditScrollRoot = ref<HTMLElement | null>(null)
const auditLoadSentinelEl = ref<HTMLElement | null>(null)
let auditScrollObserver: IntersectionObserver | null = null
const auditColumns = computed(() => [
{ colKey: 'created_at', title: t('system.globalSettings.audit.columns.time'), width: 120 },
{ colKey: 'actor', title: t('system.globalSettings.audit.columns.actor'), width: 180 },
{ colKey: 'action', title: t('system.globalSettings.audit.columns.action'), width: 150 },
{
colKey: 'target',
title: t('system.globalSettings.audit.columns.target'),
minWidth: 240,
},
{ colKey: 'outcome', title: t('system.globalSettings.audit.columns.outcome'), width: 80, align: 'center' as const },
])
function formatAuditDatePart(s: string | undefined): string {
if (!s) return '-'
try {
return new Intl.DateTimeFormat(locale.value || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(s))
} catch {
return s
}
}
function formatAuditTimePart(s: string | undefined): string {
if (!s) return ''
try {
return new Intl.DateTimeFormat(locale.value || 'zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(new Date(s))
} catch {
return ''
}
}
function auditActionTheme(
action: AuditAction,
): 'success' | 'warning' | 'danger' | 'primary' | 'default' {
switch (action) {
case 'system.admin_promoted':
return 'success'
case 'system.admin_revoked':
case 'system.setting_changed':
case 'system.queue_task_retried':
case 'system.queue_task_run_now':
return 'warning'
case 'system.user_password_reset':
case 'system.queue_task_deleted':
case 'system.queue_task_cancelled':
return 'danger'
case 'rbac.access_denied':
return 'danger'
default:
return 'default'
}
}
function auditOutcomeTheme(o: AuditOutcome): 'success' | 'danger' | 'default' {
if (o === 'denied') return 'danger'
if (o === 'success') return 'success'
return 'default'
}
function formatAuditAction(action: AuditAction): string {
const bag = tm('system.globalSettings.audit.action') as unknown
if (bag !== null && typeof bag === 'object' && typeof (bag as Record<string, string>)[action] === 'string') {
return (bag as Record<string, string>)[action]
}
return action
}
function auditActorLabel(userId: string): string {
const me = authStore.user
if (me && me.id === userId) {
return me.username?.trim() || me.email?.trim() || userId.slice(0, 8)
}
return userId.slice(0, 8)
}
function auditActorRoleLabel(role: string): string {
const key = `system.globalSettings.audit.actorRole.${role}`
if (te(key)) return t(key)
return role
}
function auditDetailsObject(row: AuditLog): Record<string, unknown> | null {
if (row.details && typeof row.details === 'object') {
return row.details as Record<string, unknown>
}
return null
}
function auditTargetKey(row: AuditLog): string {
const details = auditDetailsObject(row)
if (row.action === 'system.setting_changed') {
if (row.target_type === 'tenant_storage_quota') {
return t('system.globalSettings.audit.target.bulkQuota')
}
if (details && typeof details.key === 'string' && details.key) return details.key
return row.target_id || row.target_type || ''
}
if (
row.action === 'system.admin_promoted'
|| row.action === 'system.admin_revoked'
|| row.action === 'system.user_password_reset'
) {
if (!details) return row.target_user_id ? row.target_user_id.slice(0, 8) : ''
const name = typeof details.target_username === 'string' ? details.target_username : ''
const mail = typeof details.target_email === 'string' ? details.target_email : ''
if (name && mail) return `${name} (${mail})`
return name || mail || (row.target_user_id ? row.target_user_id.slice(0, 8) : '')
}
if (
row.action === 'system.queue_task_retried'
|| row.action === 'system.queue_task_run_now'
|| row.action === 'system.queue_task_cancelled'
|| row.action === 'system.queue_task_deleted'
) {
const queue = details && typeof details.queue === 'string' ? details.queue : ''
const taskID = details && typeof details.task_id === 'string' ? details.task_id : row.target_id
return queue && taskID ? `${queue}:${taskID}` : taskID || queue
}
if (row.target_user_id) return row.target_user_id.slice(0, 8)
if (row.target_id) {
return row.target_type ? `${row.target_type}:${row.target_id}` : row.target_id
}
return ''
}
function auditTargetDiff(row: AuditLog): string {
const details = auditDetailsObject(row)
if (!details) return ''
if (row.action === 'system.setting_changed') {
if (row.target_type === 'tenant_storage_quota') {
const affected = typeof details.affected === 'number' ? details.affected : null
const gb = typeof details.quota_gb === 'number' ? details.quota_gb : null
if (affected !== null && gb !== null) {
return t('system.globalSettings.audit.target.bulkQuotaDiff', {
count: String(affected),
gb: String(gb),
})
}
return ''
}
return formatSettingDiff(details)
}
if (row.action === 'system.admin_promoted' && typeof details.idempotent === 'boolean') {
if (details.idempotent === true) {
return t('system.globalSettings.audit.target.promoteIdempotent')
}
return ''
}
if (row.action === 'system.admin_revoked' && typeof details.changed === 'boolean') {
if (details.changed === false) {
return t('system.globalSettings.audit.target.revokeNoop')
}
return ''
}
if (row.action === 'rbac.access_denied' && typeof details.required_role === 'string') {
return t('system.globalSettings.audit.target.requiredRole', { role: details.required_role })
}
return ''
}
const SETTING_DIFF_MAX_LEN = 80
function formatSettingDiff(details: Record<string, unknown>): string {
const fmt = (v: unknown): string => {
if (v === null || v === undefined) {
return t('system.globalSettings.audit.target.valueNull')
}
if (typeof v === 'string') return v
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
const truncate = (s: string): string =>
s.length > SETTING_DIFF_MAX_LEN ? s.slice(0, SETTING_DIFF_MAX_LEN - 1) + '…' : s
const oldStr = truncate(fmt(details.old_value))
const newStr = truncate(fmt(details.new_value))
if (oldStr === newStr) return ''
return `${oldStr} → ${newStr}`
}
const auditExpandedRowKeys = ref<number[]>([])
function onAuditExpandChange(value: (string | number)[]) {
auditExpandedRowKeys.value = value
.map((v) => (typeof v === 'number' ? v : Number(v)))
.filter((v) => Number.isFinite(v))
}
function auditDetailsJSON(row: AuditLog): string {
if (row.details === null || row.details === undefined) return '{}'
if (typeof row.details === 'string') return row.details
try {
return JSON.stringify(row.details, null, 2)
} catch {
return String(row.details)
}
}
async function loadAuditLog(reset: boolean) {
if (auditLoading.value) return
if (!reset && !auditHasMore.value) return
auditLoading.value = true
auditError.value = ''
try {
const resp = await listSystemAuditLog({
after_id: reset ? undefined : auditCursor.value || undefined,
limit: AUDIT_PAGE_SIZE,
})
if (resp.success) {
const rows = resp.data || []
auditEntries.value = reset ? rows : [...auditEntries.value, ...rows]
auditCursor.value = resp.next_cursor || 0
auditHasMore.value = !!resp.next_cursor && rows.length > 0
} else {
auditError.value = resp.message || t('system.globalSettings.audit.errors.generic')
}
} catch (err: any) {
const status = err?.status
if (status === 403) {
auditError.value = t('system.globalSettings.audit.forbidden')
} else {
auditError.value = err?.message || t('system.globalSettings.audit.errors.generic')
}
} finally {
auditLoading.value = false
}
}
function detachAuditInfiniteScroll() {
auditScrollObserver?.disconnect()
auditScrollObserver = null
}
function attachAuditInfiniteScroll() {
detachAuditInfiniteScroll()
const root = auditScrollRoot.value
const sentinel = auditLoadSentinelEl.value
if (!root || !sentinel) return
auditScrollObserver = new IntersectionObserver(
(entries) => {
const hitBottom = entries.some((e) => e.isIntersecting)
if (!hitBottom || !auditHasMore.value || auditLoading.value) return
void loadAuditLog(false)
},
{ root, rootMargin: '100px 0px', threshold: 0 },
)
auditScrollObserver.observe(sentinel)
}
function reloadAuditLog() {
auditCursor.value = 0
auditHasMore.value = true
void loadAuditLog(true)
}
watch(
() => [auditEntries.value.length, auditError.value],
async () => {
await nextTick()
if (auditError.value) {
detachAuditInfiniteScroll()
return
}
attachAuditInfiniteScroll()
},
{ flush: 'post' },
)
onMounted(async () => {
await loadAuditLog(true)
await nextTick()
attachAuditInfiniteScroll()
})
onUnmounted(() => {
detachAuditInfiniteScroll()
})
</script>
<style lang="less" scoped>
.system-audit-log {
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.section-header {
margin-bottom: 20px;
}
.audit-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.audit-page-header h2 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.section-description {
margin: 0;
color: var(--td-text-color-secondary);
font-size: 14px;
line-height: 1.5;
}
.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);
}
}
.audit-page-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.audit-page-branch {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.audit-page-branch--error {
justify-content: flex-start;
}
.audit-page-branch--empty {
justify-content: center;
align-items: center;
min-height: 280px;
}
.audit-scroll-area {
flex: 1 1 auto;
min-height: 0;
max-height: calc(100vh - 260px);
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);
}
}
.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;
vertical-align: middle;
}
}
.audit-table-shell {
&: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;
}
}
</style>
+6 -900
View File
@@ -33,23 +33,7 @@
-->
<div class="system-settings">
<div class="section-header">
<div class="section-header-row">
<h2>{{ t('system.globalSettings.title') }}</h2>
<!-- Platform audit-log entry. SystemAdmin already gated the
whole view via meta.requiresSystemAdmin (router/index.ts)
so we don't re-check role here — every visitor of this
page is eligible. Mirrors the audit button placement in
tenant settings (frontend/src/views/settings/TenantMembers.vue). -->
<t-button
variant="text"
size="small"
class="header-audit-btn"
@click="openAuditDrawer"
>
<template #icon><t-icon name="history" /></template>
{{ t('system.globalSettings.audit.tabLabel') }}
</t-button>
</div>
<h2>{{ t('system.globalSettings.title') }}</h2>
<p class="section-description">
{{ t('system.globalSettings.description') }}
</p>
@@ -65,19 +49,9 @@
</div>
<template v-else>
<div class="settings-overview">
<div class="auto-save-note">
<t-icon name="check-circle" />
<span>{{ t('system.globalSettings.autoSaveHint') }}</span>
</div>
<div class="settings-overview-tags" aria-live="polite">
<t-tag theme="success" variant="light" size="small">
{{ t('system.globalSettings.summary.overridden', { count: overriddenCount }) }}
</t-tag>
<t-tag theme="warning" variant="light" size="small">
{{ t('system.globalSettings.summary.restart', { count: restartRequiredCount }) }}
</t-tag>
</div>
<div class="auto-save-note">
<t-icon name="check-circle" />
<span>{{ t('system.globalSettings.autoSaveHint') }}</span>
</div>
<!-- Resolver details matter, but are secondary to finding the setting
@@ -511,151 +485,11 @@
</t-form-item>
</t-form>
</t-dialog>
<!-- Platform audit-log drawer. Lazy-loaded on first open; closing
and reopening doesn't re-fetch (refresh is explicit via the
button inside the drawer). Backend route is SystemAdmin-gated,
and this whole view is too, so we don't bother with a role
check — any visitor here is eligible to read the feed. -->
<t-drawer
v-model:visible="auditDrawerVisible"
:header="t('system.globalSettings.audit.tabLabel')"
drawer-class-name="system-settings-audit-drawer"
size="880px"
:footer="false"
placement="right"
destroy-on-close
>
<div class="audit-drawer-inner audit-panel audit-panel--drawer">
<div class="audit-header">
<span class="audit-desc">{{ t('system.globalSettings.audit.description') }}</span>
<t-button
variant="text"
size="small"
class="audit-refresh-btn"
:loading="auditLoading"
:disabled="auditLoading"
@click="reloadAuditLog"
>
<template #icon><t-icon name="refresh" /></template>
{{ t('system.globalSettings.audit.refresh') }}
</t-button>
</div>
<div class="audit-drawer-fill">
<div v-if="auditError" class="audit-drawer-branch audit-drawer-branch--error">
<div class="error-inline">
<t-alert theme="error" :message="auditError">
<template #operation>
<t-button size="small" @click="reloadAuditLog">
{{ t('system.globalSettings.audit.retry') }}
</t-button>
</template>
</t-alert>
</div>
</div>
<div
v-else-if="!auditLoading && auditEntries.length === 0"
class="audit-drawer-branch audit-drawer-branch--empty empty-state empty-state--audit"
>
<t-empty :description="t('system.globalSettings.audit.empty')" />
</div>
<div v-else class="audit-scroll-area narrow-scrollbar audit-drawer-branch" ref="auditScrollRoot">
<div class="data-table-shell audit-table-shell">
<t-table
row-key="id"
:data="auditEntries"
:columns="auditColumns"
size="medium"
hover
expand-on-row-click
:expanded-row-keys="auditExpandedRowKeys"
@expand-change="onAuditExpandChange"
>
<template #created_at="{ row }">
<div class="audit-time">
<span class="audit-time-date">{{ formatAuditDatePart(row.created_at) }}</span>
<span class="audit-time-clock">{{ formatAuditTimePart(row.created_at) }}</span>
</div>
</template>
<template #actor="{ row }">
<div class="audit-actor">
<span class="audit-actor-name">
{{ row.actor_user_id ? auditActorLabel(row.actor_user_id) :
t('system.globalSettings.audit.systemActor') }}
</span>
<span v-if="row.actor_role" class="audit-actor-role">
{{ auditActorRoleLabel(row.actor_role) }}
</span>
</div>
</template>
<template #action="{ row }">
<t-tag :theme="auditActionTheme(row.action)" size="small" variant="light-outline">
{{ formatAuditAction(row.action) }}
</t-tag>
</template>
<template #target="{ row }">
<div class="audit-target">
<span v-if="auditTargetKey(row)" class="audit-target-key">{{ auditTargetKey(row) }}</span>
<span v-if="auditTargetDiff(row)" class="audit-target-diff">{{ auditTargetDiff(row) }}</span>
<span v-else-if="!auditTargetKey(row)" class="audit-target-empty">—</span>
</div>
</template>
<template #outcome="{ row }">
<t-tag :theme="auditOutcomeTheme(row.outcome)" size="small" variant="light">
{{ t('system.globalSettings.audit.outcome.' + row.outcome) }}
</t-tag>
</template>
<template #expandedRow="{ row }">
<div class="audit-expanded">
<div class="audit-expanded-grid">
<div class="audit-expanded-cell">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.actorId') }}</span>
<span class="audit-expanded-value mono">{{ row.actor_user_id || '—' }}</span>
</div>
<div v-if="row.target_user_id" class="audit-expanded-cell">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.targetUserId') }}</span>
<span class="audit-expanded-value mono">{{ row.target_user_id }}</span>
</div>
<div v-if="row.target_type" class="audit-expanded-cell">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.targetType') }}</span>
<span class="audit-expanded-value mono">{{ row.target_type }}</span>
</div>
<div v-if="row.target_id" class="audit-expanded-cell">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.targetId') }}</span>
<span class="audit-expanded-value mono">{{ row.target_id }}</span>
</div>
</div>
<div class="audit-expanded-details">
<span class="audit-expanded-label">{{ t('system.globalSettings.audit.expanded.details') }}</span>
<pre class="audit-expanded-json mono">{{ auditDetailsJSON(row) }}</pre>
</div>
</div>
</template>
</t-table>
</div>
<div ref="auditLoadSentinelEl" class="audit-load-sentinel" aria-hidden="true" />
<div v-if="auditLoading && auditEntries.length > 0" class="audit-loading-more">
<t-loading size="small" />
<span>{{ t('system.globalSettings.audit.loading') }}</span>
</div>
<p v-if="!auditHasMore && auditEntries.length > 0 && !auditLoading" class="audit-end-hint">
{{ t('system.globalSettings.audit.end') }}
</p>
</div>
</div>
</div>
</t-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { MessagePlugin } from 'tdesign-vue-next'
import type { FormInstanceFunctions, FormRule } from 'tdesign-vue-next'
@@ -668,11 +502,7 @@ import {
promoteUserToSystemAdmin,
revokeSystemAdmin,
resetUserPassword,
listSystemAuditLog,
type SystemSettingItem,
type AuditLog,
type AuditAction,
type AuditOutcome,
} from '@/api/system'
import { useAuthStore } from '@/stores/auth'
@@ -845,8 +675,6 @@ const activeSectionDescription = computed(() =>
t(`system.globalSettings.sections.${activeSettingsSection.value}.description`),
)
const overriddenCount = computed(() => settings.value.filter(hasOverride).length)
const restartRequiredCount = computed(() => settings.value.filter((item) => item.requires_restart).length)
function sectionTabLabel(section: SettingsSection): string {
const count = section === 'other'
@@ -1465,374 +1293,7 @@ onMounted(() => {
loadAdmins()
})
// ---- Platform audit log (system-scope, tenant_id=0) ---------------------
//
// Wired against GET /api/v1/system/admin/audit-log (SystemAdmin only).
// The drawer mirrors the structural choices of the tenant audit drawer
// in frontend/src/views/settings/TenantMembers.vue: cursor-paged by
// descending id, lazy-loaded on first open, infinite-scroll via an
// IntersectionObserver pinned to the scroll root. Refresh is explicit
// via a button inside the drawer so closing/reopening doesn't quietly
// fire a new fetch the operator didn't ask for.
const auditDrawerVisible = ref(false)
const auditEntries = ref<AuditLog[]>([])
const auditLoading = ref(false)
const auditError = ref('')
const auditCursor = ref<number>(0) // 0 = "from the top"
const auditHasMore = ref(true)
const auditLoadedOnce = ref(false)
const AUDIT_PAGE_SIZE = 50
const auditScrollRoot = ref<HTMLElement | null>(null)
const auditLoadSentinelEl = ref<HTMLElement | null>(null)
let auditScrollObserver: IntersectionObserver | null = null
// We render a stacked "date / time" cell rather than ellipsing a single
// flat string — the screenshot review surfaced that the joined form
// reads as a wall of identical timestamps when 50 events fall in the
// same minute. A two-line cell also frees horizontal space for the
// (much more important) target diff column.
const auditColumns = computed(() => [
{ colKey: 'created_at', title: t('system.globalSettings.audit.columns.time'), width: 120 },
{ colKey: 'actor', title: t('system.globalSettings.audit.columns.actor'), width: 180 },
{ colKey: 'action', title: t('system.globalSettings.audit.columns.action'), width: 150 },
{
colKey: 'target',
title: t('system.globalSettings.audit.columns.target'),
// No fixed width / no ellipsis: this is where the diff content
// lives, and clipping it to "..." negates the entire reason we
// synthesise the cell in the first place. CSS handles wrapping.
minWidth: 240,
},
{ colKey: 'outcome', title: t('system.globalSettings.audit.columns.outcome'), width: 80, align: 'center' as const },
])
// Two helpers feeding the stacked time cell. Falling back to the raw
// string keeps the table readable when Intl chokes on a malformed
// timestamp (shouldn't happen, but cheap to defend).
function formatAuditDatePart(s: string | undefined): string {
if (!s) return '-'
try {
return new Intl.DateTimeFormat(locale.value || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(s))
} catch {
return s
}
}
function formatAuditTimePart(s: string | undefined): string {
if (!s) return ''
try {
return new Intl.DateTimeFormat(locale.value || 'zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(new Date(s))
} catch {
return ''
}
}
// Action chip colour: promote is reassuring green; revoke / setting
// change are worth a second look (warning orange); denied / access
// rejections show danger so an operator can scan a chronological feed
// and immediately spot abuse.
function auditActionTheme(
action: AuditAction,
): 'success' | 'warning' | 'danger' | 'primary' | 'default' {
switch (action) {
case 'system.admin_promoted':
return 'success'
case 'system.admin_revoked':
case 'system.setting_changed':
case 'system.queue_task_retried':
case 'system.queue_task_run_now':
return 'warning'
case 'system.user_password_reset':
case 'system.queue_task_deleted':
case 'system.queue_task_cancelled':
return 'danger'
case 'rbac.access_denied':
return 'danger'
default:
return 'default'
}
}
function auditOutcomeTheme(o: AuditOutcome): 'success' | 'danger' | 'default' {
if (o === 'denied') return 'danger'
if (o === 'success') return 'success'
return 'default'
}
// i18n 键名含点号(system.setting_changed)。用 t(path) 会按路径拆开解析,
// 无法命中 system.globalSettings.audit.action['system.*'] — 必须 tm + 字面量键。
function formatAuditAction(action: AuditAction): string {
const bag = tm('system.globalSettings.audit.action') as unknown
if (bag !== null && typeof bag === 'object' && typeof (bag as Record<string, string>)[action] === 'string') {
return (bag as Record<string, string>)[action]
}
return action
}
// Actor display: most system-admin operations are performed by humans
// whose username we don't have a local mirror of. The audit row only
// carries the UUID, so we fall back to a short prefix for readability.
// If the actor is the current user, we resolve to their own profile.
function auditActorLabel(userId: string): string {
const me = authStore.user
if (me && me.id === userId) {
return me.username?.trim() || me.email?.trim() || userId.slice(0, 8)
}
return userId.slice(0, 8)
}
function auditActorRoleLabel(role: string): string {
const key = `system.globalSettings.audit.actorRole.${role}`
if (te(key)) return t(key)
return role
}
// Target rendering is split into two pieces so the table cell can
// show a structural "subject" (key / user) on its own line and the
// value diff on a second, monospaced line — far more legible than a
// single concatenated string clipped by ellipsis.
function auditDetailsObject(row: AuditLog): Record<string, unknown> | null {
if (row.details && typeof row.details === 'object') {
return row.details as Record<string, unknown>
}
return null
}
// First line of the target cell — the thing being acted on.
// - setting_changed (regular key): the registry key
// - setting_changed (bulk apply): i18n label "(bulk) default storage quota"
// - admin_promoted/revoked: username (email) of the affected user
function auditTargetKey(row: AuditLog): string {
const details = auditDetailsObject(row)
if (row.action === 'system.setting_changed') {
if (row.target_type === 'tenant_storage_quota') {
return t('system.globalSettings.audit.target.bulkQuota')
}
if (details && typeof details.key === 'string' && details.key) return details.key
return row.target_id || row.target_type || ''
}
if (
row.action === 'system.admin_promoted'
|| row.action === 'system.admin_revoked'
|| row.action === 'system.user_password_reset'
) {
if (!details) return row.target_user_id ? row.target_user_id.slice(0, 8) : ''
const name = typeof details.target_username === 'string' ? details.target_username : ''
const mail = typeof details.target_email === 'string' ? details.target_email : ''
if (name && mail) return `${name} (${mail})`
return name || mail || (row.target_user_id ? row.target_user_id.slice(0, 8) : '')
}
if (
row.action === 'system.queue_task_retried'
|| row.action === 'system.queue_task_run_now'
|| row.action === 'system.queue_task_cancelled'
|| row.action === 'system.queue_task_deleted'
) {
const queue = details && typeof details.queue === 'string' ? details.queue : ''
const taskID = details && typeof details.task_id === 'string' ? details.task_id : row.target_id
return queue && taskID ? `${queue}:${taskID}` : taskID || queue
}
if (row.target_user_id) return row.target_user_id.slice(0, 8)
if (row.target_id) {
return row.target_type ? `${row.target_type}:${row.target_id}` : row.target_id
}
return ''
}
// Second line — the change diff. Returns an empty string when there
// is no meaningful diff to display (the expanded row still surfaces
// the raw JSON for forensics).
function auditTargetDiff(row: AuditLog): string {
const details = auditDetailsObject(row)
if (!details) return ''
if (row.action === 'system.setting_changed') {
if (row.target_type === 'tenant_storage_quota') {
const affected = typeof details.affected === 'number' ? details.affected : null
const gb = typeof details.quota_gb === 'number' ? details.quota_gb : null
if (affected !== null && gb !== null) {
return t('system.globalSettings.audit.target.bulkQuotaDiff', {
count: String(affected),
gb: String(gb),
})
}
return ''
}
return formatSettingDiff(details)
}
if (row.action === 'system.admin_promoted' && typeof details.idempotent === 'boolean') {
if (details.idempotent === true) {
return t('system.globalSettings.audit.target.promoteIdempotent')
}
return ''
}
if (row.action === 'system.admin_revoked' && typeof details.changed === 'boolean') {
if (details.changed === false) {
return t('system.globalSettings.audit.target.revokeNoop')
}
return ''
}
if (row.action === 'rbac.access_denied' && typeof details.required_role === 'string') {
return t('system.globalSettings.audit.target.requiredRole', { role: details.required_role })
}
return ''
}
const SETTING_DIFF_MAX_LEN = 80
function formatSettingDiff(details: Record<string, unknown>): string {
const fmt = (v: unknown): string => {
if (v === null || v === undefined) {
return t('system.globalSettings.audit.target.valueNull')
}
if (typeof v === 'string') return v
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
const truncate = (s: string): string =>
s.length > SETTING_DIFF_MAX_LEN ? s.slice(0, SETTING_DIFF_MAX_LEN - 1) + '…' : s
const oldStr = truncate(fmt(details.old_value))
const newStr = truncate(fmt(details.new_value))
if (oldStr === newStr) return ''
return `${oldStr} → ${newStr}`
}
// Expanded row state — local set of row ids the user has opened.
// We keep it ephemeral (not persisted) so reopening the drawer always
// shows a clean, collapsed view.
const auditExpandedRowKeys = ref<number[]>([])
function onAuditExpandChange(value: (string | number)[]) {
// t-table calls back with the *new* full list of expanded keys.
// Normalise to numbers because AuditLog.id is always a number.
auditExpandedRowKeys.value = value
.map((v) => (typeof v === 'number' ? v : Number(v)))
.filter((v) => Number.isFinite(v))
}
function auditDetailsJSON(row: AuditLog): string {
if (row.details === null || row.details === undefined) return '{}'
if (typeof row.details === 'string') return row.details
try {
return JSON.stringify(row.details, null, 2)
} catch {
return String(row.details)
}
}
async function loadAuditLog(reset: boolean) {
if (auditLoading.value) return
if (!reset && !auditHasMore.value) return
auditLoading.value = true
auditError.value = ''
try {
const resp = await listSystemAuditLog({
after_id: reset ? undefined : auditCursor.value || undefined,
limit: AUDIT_PAGE_SIZE,
})
if (resp.success) {
const rows = resp.data || []
auditEntries.value = reset ? rows : [...auditEntries.value, ...rows]
auditCursor.value = resp.next_cursor || 0
// Same convention as tenant audit: next_cursor=0 means "no
// older rows", regardless of whether the current page was empty.
auditHasMore.value = !!resp.next_cursor && rows.length > 0
auditLoadedOnce.value = true
} else {
auditError.value = resp.message || t('system.globalSettings.audit.errors.generic')
}
} catch (err: any) {
const status = err?.status
if (status === 403) {
auditError.value = t('system.globalSettings.audit.forbidden')
} else {
auditError.value = err?.message || t('system.globalSettings.audit.errors.generic')
}
} finally {
auditLoading.value = false
}
}
function detachAuditInfiniteScroll() {
auditScrollObserver?.disconnect()
auditScrollObserver = null
}
function attachAuditInfiniteScroll() {
detachAuditInfiniteScroll()
const root = auditScrollRoot.value
const sentinel = auditLoadSentinelEl.value
if (!root || !sentinel) return
auditScrollObserver = new IntersectionObserver(
(entries) => {
const hitBottom = entries.some((e) => e.isIntersecting)
if (!hitBottom || !auditHasMore.value || auditLoading.value) return
void loadAuditLog(false)
},
{ root, rootMargin: '100px 0px', threshold: 0 },
)
auditScrollObserver.observe(sentinel)
}
function reloadAuditLog() {
auditCursor.value = 0
auditHasMore.value = true
loadAuditLog(true)
}
function openAuditDrawer() {
auditDrawerVisible.value = true
if (!auditLoadedOnce.value) {
loadAuditLog(true)
}
}
watch(
auditDrawerVisible,
async (open) => {
if (!open) {
detachAuditInfiniteScroll()
return
}
await nextTick()
attachAuditInfiniteScroll()
},
{ flush: 'post' },
)
watch(
() => auditError.value,
async () => {
if (!auditDrawerVisible.value) return
await nextTick()
if (!auditError.value) {
attachAuditInfiniteScroll()
return
}
detachAuditInfiniteScroll()
},
{ flush: 'post' },
)
onUnmounted(() => {
detachAuditInfiniteScroll()
if (savedKeyTimer) clearTimeout(savedKeyTimer)
})
</script>
@@ -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 <AuditLogPanel> 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;
}
</style>
@@ -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{}).
+24 -3
View File
@@ -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 {
@@ -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()
+142 -3
View File
@@ -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",
+22 -14
View File
@@ -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,
+4
View File
@@ -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.
+10
View File
@@ -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
}
+29
View File
@@ -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) {
+125 -18
View File
@@ -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,
})
@@ -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)
}
}
+8 -3
View File
@@ -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)
@@ -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
+8
View File
@@ -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)
+44 -22
View File
@@ -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)
}
}
}
@@ -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{}
+2
View File
@@ -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
+5
View File
@@ -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.
+1
View File
@@ -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"
+66 -10
View File
@@ -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)
@@ -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);
@@ -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);
@@ -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;
@@ -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 $$;