From e849e6fc47286c0db19783a1c26b9567e6483673 Mon Sep 17 00:00:00 2001 From: MercuialC Date: Tue, 15 Sep 2026 10:35:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20API=20Key=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8C=89=E7=9F=A5=E8=AF=86=E5=BA=93=E9=85=8D=E7=BD=AE=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB/=E5=8F=AF=E5=86=99/=E5=8F=AF=E7=AE=A1=E7=90=86?= =?UTF-8?q?=EF=BC=8C=E5=88=97=E8=A1=A8=E6=8E=A5=E5=8F=A3=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=20permission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3276 --- cli/cmd/kb/list.go | 1 + client/knowledgebase.go | 17 +- client/tenant.go | 51 ++-- docs/api/knowledge-base.md | 4 +- frontend/src/api/tenant/index.ts | 3 + frontend/src/i18n/locales/en-US.ts | 5 + frontend/src/i18n/locales/ko-KR.ts | 5 + frontend/src/i18n/locales/ru-RU.ts | 5 + frontend/src/i18n/locales/zh-CN.ts | 5 + .../integrations/ApiIntegrationSettings.vue | 217 +++++++++++++++++- .../views/integrations/apiKeyScope.test.ts | 54 ++++- .../src/views/integrations/apiKeyScope.ts | 90 ++++++++ .../src/views/knowledge/KnowledgeBaseList.vue | 17 +- .../application/repository/tenant_api_key.go | 11 +- .../repository/tenant_api_key_test.go | 8 +- .../application/service/tenant_api_key.go | 61 +++-- .../service/tenant_api_key_test.go | 58 ++++- .../migration_sqlite_versioned_schema_test.go | 3 +- internal/handler/knowledgebase.go | 30 ++- .../knowledgebase_pr3_response_test.go | 33 +++ internal/handler/tenant.go | 89 ++++--- .../handler/tenant_api_key_validation_test.go | 14 ++ internal/middleware/api_key_gate.go | 20 ++ internal/middleware/auth.go | 11 +- internal/middleware/kb_access.go | 57 +++++ internal/middleware/kb_access_test.go | 66 ++++++ internal/types/interfaces/tenant.go | 30 +-- internal/types/knowledgebase_access.go | 57 +++++ internal/types/knowledgebase_access_test.go | 101 ++++++++ internal/types/tenant_api_key.go | 103 +++++++-- internal/types/tenant_api_key_permissions.go | 204 ++++++++++++++++ internal/types/tenant_api_key_scope_test.go | 145 ++++++++++++ .../000017_api_key_kb_permissions.down.sql | 1 + .../000017_api_key_kb_permissions.up.sql | 4 + .../000096_api_key_kb_permissions.down.sql | 3 + .../000096_api_key_kb_permissions.up.sql | 10 + packages/dsh-weknora/src/client.ts | 15 ++ website-docs/03-features/02-knowledge-base.md | 2 +- website-docs/04-api/02-api-knowledge.md | 4 +- 39 files changed, 1444 insertions(+), 170 deletions(-) create mode 100644 internal/types/knowledgebase_access.go create mode 100644 internal/types/knowledgebase_access_test.go create mode 100644 internal/types/tenant_api_key_permissions.go create mode 100644 migrations/sqlite/000017_api_key_kb_permissions.down.sql create mode 100644 migrations/sqlite/000017_api_key_kb_permissions.up.sql create mode 100644 migrations/versioned/000096_api_key_kb_permissions.down.sql create mode 100644 migrations/versioned/000096_api_key_kb_permissions.up.sql diff --git a/cli/cmd/kb/list.go b/cli/cmd/kb/list.go index 0e8ac64663c..762e4cc3dba 100644 --- a/cli/cmd/kb/list.go +++ b/cli/cmd/kb/list.go @@ -26,6 +26,7 @@ var kbListFields = []string{ "embedding_model_id", "summary_model_id", "knowledge_count", "chunk_count", "is_processing", "processing_count", + "permission", "created_at", "updated_at", } diff --git a/client/knowledgebase.go b/client/knowledgebase.go index dbf2186f9de..46b5d0d5feb 100644 --- a/client/knowledgebase.go +++ b/client/knowledgebase.go @@ -35,10 +35,11 @@ type KnowledgeBase struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Computed fields (not stored in database) - KnowledgeCount int64 `json:"knowledge_count"` - ChunkCount int64 `json:"chunk_count"` - IsProcessing bool `json:"is_processing"` - ProcessingCount int64 `json:"processing_count"` + KnowledgeCount int64 `json:"knowledge_count"` + ChunkCount int64 `json:"chunk_count"` + IsProcessing bool `json:"is_processing"` + ProcessingCount int64 `json:"processing_count"` + Permission KnowledgeBaseAccess `json:"permission,omitempty"` } // KnowledgeBaseConfig represents knowledge base configuration @@ -90,6 +91,14 @@ type StorageConfig struct { Provider string `json:"provider"` } +// KnowledgeBaseAccess is the caller's read / write / manage rights on one +// knowledge base, as returned on GET /knowledge-bases. +type KnowledgeBaseAccess struct { + Read bool `json:"read"` + Write bool `json:"write"` + Manage bool `json:"manage"` +} + // ExtractConfig represents the extract configuration for a knowledge base type ExtractConfig struct { Enabled bool `json:"enabled"` diff --git a/client/tenant.go b/client/tenant.go index 7c0c31ac310..edb944e03d3 100644 --- a/client/tenant.go +++ b/client/tenant.go @@ -78,38 +78,41 @@ const ( // TenantAPIKey is the API key metadata returned by list/create APIs. type TenantAPIKey struct { - ID uint64 `json:"id"` - TenantID uint64 `json:"tenant_id"` - ScopeType string `json:"scope_type"` - Name string `json:"name"` - APIKey string `json:"api_key"` - Role TenantAPIKeyRole `json:"role"` - FullAccess bool `json:"full_access"` - KnowledgeBaseIDs []string `json:"knowledge_base_ids"` - Capabilities []string `json:"capabilities"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + TenantID uint64 `json:"tenant_id"` + ScopeType string `json:"scope_type"` + Name string `json:"name"` + APIKey string `json:"api_key"` + Role TenantAPIKeyRole `json:"role"` + FullAccess bool `json:"full_access"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + KnowledgeBasePermissions map[string][]string `json:"knowledge_base_permissions,omitempty"` + Capabilities []string `json:"capabilities"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // CreateTenantAPIKeyRequest creates a revocable tenant API key. type CreateTenantAPIKeyRequest struct { - Name string `json:"name"` - Role TenantAPIKeyRole `json:"role,omitempty"` - FullAccess bool `json:"full_access,omitempty"` - KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"` - Capabilities []string `json:"capabilities,omitempty"` - ExpiresAtUnix *int64 `json:"expires_at_unix,omitempty"` + Name string `json:"name"` + Role TenantAPIKeyRole `json:"role,omitempty"` + FullAccess bool `json:"full_access,omitempty"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"` + KnowledgeBasePermissions map[string][]string `json:"knowledge_base_permissions,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + ExpiresAtUnix *int64 `json:"expires_at_unix,omitempty"` } // UpdateTenantAPIKeyRequest replaces an existing tenant API key's configurable attributes. type UpdateTenantAPIKeyRequest struct { - Name string `json:"name"` - FullAccess bool `json:"full_access"` - KnowledgeBaseIDs []string `json:"knowledge_base_ids"` - Capabilities []string `json:"capabilities"` - ExpiresAtUnix *int64 `json:"expires_at_unix"` + Name string `json:"name"` + FullAccess bool `json:"full_access"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + KnowledgeBasePermissions map[string][]string `json:"knowledge_base_permissions,omitempty"` + Capabilities []string `json:"capabilities"` + ExpiresAtUnix *int64 `json:"expires_at_unix"` } // CreatedTenantAPIKey includes the created API key. Token is kept for diff --git a/docs/api/knowledge-base.md b/docs/api/knowledge-base.md index 65f06a2e937..ff539a632e3 100644 --- a/docs/api/knowledge-base.md +++ b/docs/api/knowledge-base.md @@ -251,7 +251,7 @@ curl --location 'http://localhost:8080/api/v1/knowledge-bases' \ --header 'X-API-Key: sk-xxxxx' ``` -**响应**: `data` 为数组,每个元素的字段结构同 `POST /knowledge-bases` 响应,并额外携带 `knowledge_count` / `chunk_count` / `processing_count` / `share_count` / `is_pinned` / `pinned_at` 这些聚合与状态字段。 +**响应**: `data` 为数组,每个元素的字段结构同 `POST /knowledge-bases` 响应,并额外携带 `knowledge_count` / `chunk_count` / `processing_count` / `share_count` / `is_pinned` / `pinned_at` / `capabilities` / `permission`。`permission` 为当前调用方对该库的权限:`{ "read": bool, "write": bool, "manage": bool }`(可读 / 可写 / 可管理)。API Key 按该 Key 的全局能力与分库 overlay 计算;登录用户按空间角色(Contributor 仅对自己创建的库有写/管理)。 > **注意(Phase 2)**:列表接口不包含 `vector_store_name` / `vector_store_source` / `vector_store_engine_type` / `vector_store_status` 这四个解析后的元数据字段(避免 N+1 查询);仅 `vector_store_id` 来自数据库本身。需要展示存储名称时请单独调用详情接口或 `/vector-stores/:id`。 @@ -279,7 +279,7 @@ curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001' \ --header 'X-API-Key: sk-xxxxx' ``` -**响应**: 字段结构同 `POST /knowledge-bases` 响应(包含 Phase 2 的 `vector_store_*` 元数据字段),并附 `is_pinned` / `pinned_at` / `knowledge_count` / `chunk_count` / `processing_count` 状态字段。通过共享智能体访问时还会附加 `my_permission`;同时 `vector_store_name` / `vector_store_engine_type` 会被隐藏(`vector_store_source` 返回 `"shared"`),避免跨空间泄漏存储展示名。 +**响应**: 字段结构同 `POST /knowledge-bases` 响应(包含 Phase 2 的 `vector_store_*` 元数据字段),并附 `is_pinned` / `pinned_at` / `knowledge_count` / `chunk_count` / `processing_count` / `permission`。`permission` 同列表接口。通过共享智能体访问时还会附加 `my_permission`;同时 `vector_store_name` / `vector_store_engine_type` 会被隐藏(`vector_store_source` 返回 `"shared"`),避免跨空间泄漏存储展示名。 ## PUT `/knowledge-bases/:id` - 更新知识库 diff --git a/frontend/src/api/tenant/index.ts b/frontend/src/api/tenant/index.ts index ae5baf1eefe..21588396894 100644 --- a/frontend/src/api/tenant/index.ts +++ b/frontend/src/api/tenant/index.ts @@ -100,6 +100,7 @@ export interface TenantAPIKey { api_key: string full_access: boolean knowledge_base_ids: string[] | null + knowledge_base_permissions?: Record | null capabilities?: TenantAPIKeyCapability[] last_used_at?: string expires_at?: string @@ -114,6 +115,7 @@ export interface CreateTenantAPIKeyPayload { name: string full_access?: boolean knowledge_base_ids?: string[] + knowledge_base_permissions?: Record capabilities?: TenantAPIKeyCapability[] expires_at_unix?: number } @@ -122,6 +124,7 @@ export interface UpdateTenantAPIKeyPayload { name: string full_access: boolean knowledge_base_ids: string[] + knowledge_base_permissions?: Record capabilities: TenantAPIKeyCapability[] expires_at_unix?: number } diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 87cc0a17bfe..3b73b665256 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -6679,6 +6679,11 @@ export default { capabilityManageTenantSettingsHint: 'Read and update workspace-level integration settings such as API end-user identity mode, request header configuration, and workspace KV settings. Does not include API key management, member management, workspace deletion, or ownership transfer.', apiKeyKnowledgeScope: 'Knowledge bases', apiKeyKnowledgeScopePlaceholder: 'Leave empty to allow all knowledge bases', + apiKeyKnowledgePermissionsHint: 'Defaults follow the Read / Edit / Manage capabilities checked above. Uncheck per knowledge base to narrow them; unchecking Read removes that knowledge base from the list.', + kbPermissionRead: 'Read', + kbPermissionEdit: 'Edit', + kbPermissionManage: 'Manage', + apiKeyKnowledgeScopeSummary: '{total} ({editable} editable / {manageable} manageable)', allKnowledgeBases: 'All knowledge bases', editApiKeyScope: 'Edit API key', editApiKeyScopeDesc: 'Change the name and authorization scope of API key "{name}".', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index e310b619740..d009051741d 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -591,6 +591,11 @@ export default { capabilityManageTenantSettingsHint: 'API 최종 사용자 식별 모드, 요청 헤더 설정, 워크스페이스 KV 설정 등 워크스페이스 수준 통합 설정을 읽고 업데이트합니다. API Key 관리, 멤버 관리, 워크스페이스 삭제 또는 소유권 이전은 포함하지 않습니다.', apiKeyKnowledgeScope: '지식베이스 범위', apiKeyKnowledgeScopePlaceholder: '비워 두면 모든 지식베이스 접근 허용', + apiKeyKnowledgePermissionsHint: '기본값은 위에서 선택한 읽기 / 편집 / 관리 권한을 따릅니다. 지식베이스별로 선택을 해제할 수 있으며, 읽기를 해제하면 목록에서 제거됩니다.', + kbPermissionRead: '읽기', + kbPermissionEdit: '편집', + kbPermissionManage: '관리', + apiKeyKnowledgeScopeSummary: '{total}개 (편집 {editable} / 관리 {manageable})', allKnowledgeBases: '모든 지식베이스', editApiKeyScope: 'API Key 편집', editApiKeyScopeDesc: 'API Key "{name}"의 이름과 권한 범위를 변경합니다.', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index e13ec971aa5..688723b6f0e 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -591,6 +591,11 @@ export default { capabilityManageTenantSettingsHint: 'Чтение и обновление интеграционных настроек пространства: режим идентификации конечного пользователя API, настройки заголовков запросов и KV пространства. Не включает управление API Key, участниками, удаление пространства или передачу владения.', apiKeyKnowledgeScope: 'Базы знаний', apiKeyKnowledgeScopePlaceholder: 'Оставьте пустым, чтобы разрешить доступ ко всем базам знаний', + apiKeyKnowledgePermissionsHint: 'По умолчанию следуют отмеченным выше правам чтения / правки / управления. Можно снять флажок у отдельной базы; снятие «Чтение» удаляет базу из списка.', + kbPermissionRead: 'Чтение', + kbPermissionEdit: 'Правка', + kbPermissionManage: 'Управление', + apiKeyKnowledgeScopeSummary: '{total} (правка {editable} / управление {manageable})', allKnowledgeBases: 'Все базы знаний', editApiKeyScope: 'Изменить API key', editApiKeyScopeDesc: 'Изменить имя и область авторизации API key «{name}».', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 487dc1a9813..43e94190af5 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -591,6 +591,11 @@ export default { capabilityManageTenantSettingsHint: '允许读取和更新空间级集成设置,例如 API 终端用户身份模式、请求头配置、空间 KV 配置等;不包含 API Key 管理、成员管理、空间删除或所有权转移。', apiKeyKnowledgeScope: '知识库范围', apiKeyKnowledgeScopePlaceholder: '留空表示允许访问全部知识库', + apiKeyKnowledgePermissionsHint: '默认跟随上方已勾选的可读 / 可编辑 / 可管理能力。可按知识库去掉勾选;取消可读会从名单中移除该库。', + kbPermissionRead: '可读', + kbPermissionEdit: '可编辑', + kbPermissionManage: '可管理', + apiKeyKnowledgeScopeSummary: '{total} 个(可编辑 {editable} / 可管理 {manageable})', allKnowledgeBases: '全部知识库', editApiKeyScope: '编辑 API Key', editApiKeyScopeDesc: '修改 API Key「{name}」的名称和授权范围。', diff --git a/frontend/src/views/integrations/ApiIntegrationSettings.vue b/frontend/src/views/integrations/ApiIntegrationSettings.vue index 91fa1c9d570..d599572fd6c 100644 --- a/frontend/src/views/integrations/ApiIntegrationSettings.vue +++ b/frontend/src/views/integrations/ApiIntegrationSettings.vue @@ -149,7 +149,7 @@ - {{ formatKeyKnowledgeScope(key.knowledge_base_ids) }} + {{ formatKeyKnowledgeScope(key) }} @@ -559,6 +559,37 @@ :options="knowledgeBaseOptions" :placeholder="$t('integrations.api.apiKeyKnowledgeScopePlaceholder')" /> +
+
+ {{ $t('integrations.api.apiKeyKnowledgeScope') }} + {{ $t('integrations.api.kbPermissionRead') }} + {{ $t('integrations.api.kbPermissionEdit') }} + {{ $t('integrations.api.kbPermissionManage') }} +
+
+ {{ knowledgeBaseName(kbID) }} + + + +
+
+

{{ $t('integrations.api.apiKeyKnowledgePermissionsHint') }}

@@ -662,6 +693,36 @@ :options="knowledgeBaseOptions" :placeholder="$t('integrations.api.apiKeyKnowledgeScopePlaceholder')" /> +
+
+ {{ $t('integrations.api.apiKeyKnowledgeScope') }} + {{ $t('integrations.api.kbPermissionRead') }} + {{ $t('integrations.api.kbPermissionEdit') }} + {{ $t('integrations.api.kbPermissionManage') }} +
+
+ {{ knowledgeBaseName(kbID) }} + + + +
+

{{ $t('integrations.api.editApiKeyScopeHint') }}

@@ -700,7 +761,16 @@ import { TENANT_API_KEY_CAPABILITY_GROUPS, type ApiKeyCapabilityGroup, } from '@/config/apiKeyCapabilities' -import { normalizeAPIKeyKnowledgeBaseIDs } from './apiKeyScope' +import { + buildKnowledgeBasePermissionsPayload, + defaultKnowledgeBaseGrants, + knowledgeBaseGrantsForID, + normalizeAPIKeyKnowledgeBaseIDs, + normalizeKnowledgeBasePermissions, + summarizeKnowledgeBasePermissions, + type KnowledgeBasePermissionCapability, + type KnowledgeBasePermissionMap, +} from './apiKeyScope' import { consumeApiPlaygroundSSE } from './apiPlaygroundSSE' const { t } = useI18n() @@ -770,6 +840,7 @@ const editingCapabilitySelections = reactive ( )) watch(editingKnowledgeScopeApplies, (applies) => { - if (!applies) editingAPIKeyForm.knowledge_base_ids = [] + if (!applies) { + editingAPIKeyForm.knowledge_base_ids = [] + editingAPIKeyForm.knowledge_base_permissions = {} + } }) function editingCapabilityGroupAllSelected(group: ApiKeyCapabilityGroup): boolean { @@ -806,6 +880,7 @@ function toggleEditingCapabilityGroup(group: ApiKeyCapabilityGroup, selected: bo const apiKeyForm = reactive({ name: '', knowledge_base_ids: [] as string[], + knowledge_base_permissions: {} as KnowledgeBasePermissionMap, // Tenant-full keys already cover every capability. Scoped keys default to // retrieval + chat + agent reads so a fresh integration can ask questions // and present an agent picker immediately. @@ -828,15 +903,84 @@ const apiKeyKnowledgeScopeApplies = computed(() => ( watch(() => apiKeyForm.tenant_full_enabled, (enabled) => { if (enabled) { apiKeyForm.knowledge_base_ids = [] + apiKeyForm.knowledge_base_permissions = {} } }) watch(apiKeyKnowledgeScopeApplies, (applies) => { if (!applies) { apiKeyForm.knowledge_base_ids = [] + apiKeyForm.knowledge_base_permissions = {} } }) +function knowledgeBaseName(kbID: string) { + return knowledgeBases.value.find((kb) => kb.id === kbID)?.name || kbID +} + +function kbPermissionColumnEnabled( + capabilities: readonly TenantAPIKeyCapability[], + cap: KnowledgeBasePermissionCapability, +) { + if (cap === 'retrieve') return capabilities.includes('retrieve') || capabilities.includes('chat') + return capabilities.includes(cap) +} + +function hasKBPermission( + form: { knowledge_base_ids: string[]; knowledge_base_permissions: KnowledgeBasePermissionMap }, + capabilities: readonly TenantAPIKeyCapability[], + kbID: string, + cap: KnowledgeBasePermissionCapability, +) { + return knowledgeBaseGrantsForID( + kbID, + form.knowledge_base_ids, + form.knowledge_base_permissions, + capabilities, + ).includes(cap) +} + +function setKBPermission( + form: { knowledge_base_ids: string[]; knowledge_base_permissions: KnowledgeBasePermissionMap }, + capabilities: readonly TenantAPIKeyCapability[], + kbID: string, + cap: KnowledgeBasePermissionCapability, + checked: unknown, +) { + const enabled = Boolean(checked) + if (cap === 'retrieve' && !enabled) { + form.knowledge_base_ids = form.knowledge_base_ids.filter((id) => id !== kbID) + const next = { ...form.knowledge_base_permissions } + delete next[kbID] + form.knowledge_base_permissions = next + return + } + const defaults = defaultKnowledgeBaseGrants(capabilities) + const current = new Set( + knowledgeBaseGrantsForID(kbID, form.knowledge_base_ids, form.knowledge_base_permissions, capabilities), + ) + if (enabled) current.add(cap) + else current.delete(cap) + const nextGrants = defaults.filter((item) => current.has(item)) + form.knowledge_base_permissions = { + ...form.knowledge_base_permissions, + [kbID]: nextGrants, + } +} + +function permissionsPayload( + form: { knowledge_base_ids: string[]; knowledge_base_permissions: KnowledgeBasePermissionMap }, + capabilities: readonly TenantAPIKeyCapability[], + applies: boolean, +) { + if (!applies) return {} + return buildKnowledgeBasePermissionsPayload( + form.knowledge_base_ids, + form.knowledge_base_permissions, + capabilities, + ) +} + function selectedCapabilities(): TenantAPIKeyCapability[] { return selectedCapabilityValues.value } @@ -1437,6 +1581,7 @@ function openApiDoc() { function openCreateAPIKeyDialog() { apiKeyForm.name = '' apiKeyForm.knowledge_base_ids = [] + apiKeyForm.knowledge_base_permissions = {} apiKeyForm.tenant_full_enabled = false API_KEY_CAPABILITIES.forEach((capability) => { capabilitySelections[capability] = DEFAULT_API_KEY_CAPABILITIES.has(capability) @@ -1461,6 +1606,11 @@ async function createScopedAPIKey() { full_access: apiKeyFullAccessEnabled.value, // KB scoping only applies to capabilities that touch knowledge bases. knowledge_base_ids: apiKeyKnowledgeScopeApplies.value ? apiKeyForm.knowledge_base_ids : [], + knowledge_base_permissions: permissionsPayload( + apiKeyForm, + selectedCapabilities(), + apiKeyKnowledgeScopeApplies.value, + ), // Capabilities only matter below full access; full access already covers them all. capabilities: apiKeyFullAccessEnabled.value ? [] : selectedCapabilities(), }) @@ -1484,6 +1634,7 @@ function openEditAPIKeyScope(key: TenantAPIKey) { editingAPIKeyForm.name = key.name editingAPIKeyForm.tenant_full_enabled = key.full_access editingAPIKeyForm.knowledge_base_ids = normalizeAPIKeyKnowledgeBaseIDs(key.knowledge_base_ids) + editingAPIKeyForm.knowledge_base_permissions = normalizeKnowledgeBasePermissions(key.knowledge_base_permissions) const expiresAt = key.expires_at ? Date.parse(key.expires_at) : Number.NaN editingAPIKeyForm.expires_at_unix = Number.isNaN(expiresAt) ? undefined @@ -1515,6 +1666,11 @@ async function saveAPIKeyConfiguration() { full_access: editingAPIKeyFullAccessEnabled.value, capabilities: editingAPIKeyFullAccessEnabled.value ? [] : editingSelectedCapabilities.value, knowledge_base_ids: editingKnowledgeScopeApplies.value ? editingAPIKeyForm.knowledge_base_ids : [], + knowledge_base_permissions: permissionsPayload( + editingAPIKeyForm, + editingSelectedCapabilities.value, + editingKnowledgeScopeApplies.value, + ), expires_at_unix: editingAPIKeyForm.expires_at_unix, }) if (!resp.success || !resp.data) { @@ -1555,11 +1711,19 @@ async function deleteScopedAPIKey(id: number) { await loadAPIKeys() } -function formatKeyKnowledgeScope(ids: readonly string[] | null | undefined) { - const normalizedIDs = normalizeAPIKeyKnowledgeBaseIDs(ids) +function formatKeyKnowledgeScope(key: TenantAPIKey) { + const normalizedIDs = normalizeAPIKeyKnowledgeBaseIDs(key.knowledge_base_ids) if (!normalizedIDs.length) return t('integrations.api.allKnowledgeBases') - const names = normalizedIDs.map((id) => knowledgeBases.value.find((kb) => kb.id === id)?.name || id) - return names.join(', ') + const summary = summarizeKnowledgeBasePermissions( + normalizedIDs, + key.knowledge_base_permissions, + key.capabilities || [], + ) + return t('integrations.api.apiKeyKnowledgeScopeSummary', { + total: summary.total, + editable: summary.editable, + manageable: summary.manageable, + }) } function formatKeyMaskedValue(key: TenantAPIKey) { @@ -2168,6 +2332,45 @@ onBeforeUnmount(stopPlayground) line-height: 18px; } +.kb-permission-matrix { + display: flex; + flex-direction: column; + gap: 0; + margin-top: 8px; + border: 1px solid var(--td-component-stroke); + border-radius: 6px; + overflow: hidden; +} + +.kb-permission-matrix__head, +.kb-permission-matrix__row { + display: grid; + grid-template-columns: minmax(0, 1fr) 64px 64px 64px; + gap: 8px; + align-items: center; + padding: 8px 12px; +} + +.kb-permission-matrix__head { + background: var(--td-bg-color-secondarycontainer); + color: var(--td-text-color-secondary); + font-size: 12px; + font-weight: 600; +} + +.kb-permission-matrix__row + .kb-permission-matrix__row { + border-top: 1px solid var(--td-component-stroke); +} + +.kb-permission-matrix__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--td-text-color-primary); + font-size: 13px; +} + .principal-section { display: flex; flex-direction: column; diff --git a/frontend/src/views/integrations/apiKeyScope.test.ts b/frontend/src/views/integrations/apiKeyScope.test.ts index 47a54c217cd..174ae41212d 100644 --- a/frontend/src/views/integrations/apiKeyScope.test.ts +++ b/frontend/src/views/integrations/apiKeyScope.test.ts @@ -1,7 +1,13 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { normalizeAPIKeyKnowledgeBaseIDs } from './apiKeyScope.ts' +import { + buildKnowledgeBasePermissionsPayload, + defaultKnowledgeBaseGrants, + knowledgeBaseGrantsForID, + normalizeAPIKeyKnowledgeBaseIDs, + summarizeKnowledgeBasePermissions, +} from './apiKeyScope.ts' /** * 验证完全授权 Key 的 null/undefined 范围会转换为空数组,避免列表渲染读取 length 时白屏。 @@ -24,3 +30,49 @@ test('copies configured API key knowledge base scope', () => { assert.notEqual(normalized, ids) assert.deepEqual(normalizeAPIKeyKnowledgeBaseIDs([]), []) }) + +test('defaults per-KB grants from global retrieve/ingest/manage_kbs', () => { + assert.deepEqual(defaultKnowledgeBaseGrants(['retrieve', 'chat']), ['retrieve']) + assert.deepEqual( + defaultKnowledgeBaseGrants(['chat', 'ingest', 'manage_kbs']), + ['retrieve', 'ingest', 'manage_kbs'], + ) +}) + +test('inherits global grants when a KB has no overlay entry', () => { + const grants = knowledgeBaseGrantsForID( + 'kb-2', + ['kb-1', 'kb-2'], + { 'kb-1': ['retrieve'] }, + ['retrieve', 'ingest'], + ) + assert.deepEqual(grants, ['retrieve', 'ingest']) +}) + +test('omits payload when every KB still matches the global default', () => { + const payload = buildKnowledgeBasePermissionsPayload( + ['kb-1', 'kb-2'], + { 'kb-1': ['retrieve', 'ingest'] }, + ['retrieve', 'ingest'], + ) + assert.deepEqual(payload, {}) +}) + +test('sends an overlay when one KB has ingest removed', () => { + const payload = buildKnowledgeBasePermissionsPayload( + ['kb-1', 'kb-2'], + { 'kb-1': ['retrieve'] }, + ['retrieve', 'ingest'], + ) + assert.deepEqual(payload['kb-1'], ['retrieve']) + assert.deepEqual(payload['kb-2'], ['retrieve', 'ingest']) +}) + +test('summarizes editable and manageable counts from the overlay', () => { + const summary = summarizeKnowledgeBasePermissions( + ['kb-1', 'kb-2', 'kb-3'], + { 'kb-1': ['retrieve'], 'kb-3': ['retrieve', 'ingest', 'manage_kbs'] }, + ['retrieve', 'ingest', 'manage_kbs'], + ) + assert.deepEqual(summary, { total: 3, editable: 2, manageable: 2 }) +}) diff --git a/frontend/src/views/integrations/apiKeyScope.ts b/frontend/src/views/integrations/apiKeyScope.ts index b4183967c75..8378a68d6fc 100644 --- a/frontend/src/views/integrations/apiKeyScope.ts +++ b/frontend/src/views/integrations/apiKeyScope.ts @@ -1,3 +1,9 @@ +export const KB_PERMISSION_CAPABILITIES = ['retrieve', 'ingest', 'manage_kbs'] as const + +export type KnowledgeBasePermissionCapability = (typeof KB_PERMISSION_CAPABILITIES)[number] + +export type KnowledgeBasePermissionMap = Record + /** * 将 API 返回的知识库范围归一化为前端表单可安全使用的数组。 * @@ -9,3 +15,87 @@ export function normalizeAPIKeyKnowledgeBaseIDs( ): string[] { return ids ? [...ids] : [] } + +export function normalizeKnowledgeBasePermissions( + perms: KnowledgeBasePermissionMap | null | undefined, +): KnowledgeBasePermissionMap { + if (!perms) return {} + const out: KnowledgeBasePermissionMap = {} + for (const [kbID, grants] of Object.entries(perms)) { + const id = kbID.trim() + if (!id) continue + out[id] = [...(grants || [])] + } + return out +} + +export function hasKBReadCeiling(capabilities: readonly string[]): boolean { + return capabilities.includes('retrieve') || capabilities.includes('chat') +} + +export function defaultKnowledgeBaseGrants( + capabilities: readonly string[], +): KnowledgeBasePermissionCapability[] { + const grants: KnowledgeBasePermissionCapability[] = [] + if (hasKBReadCeiling(capabilities)) grants.push('retrieve') + if (capabilities.includes('ingest')) grants.push('ingest') + if (capabilities.includes('manage_kbs')) grants.push('manage_kbs') + return grants +} + +export function knowledgeBaseGrantsForID( + kbID: string, + ids: readonly string[], + perms: KnowledgeBasePermissionMap, + capabilities: readonly string[], +): KnowledgeBasePermissionCapability[] { + const defaults = defaultKnowledgeBaseGrants(capabilities) + if (!ids.includes(kbID)) return [] + const stored = perms[kbID] + if (!stored) return defaults + return KB_PERMISSION_CAPABILITIES.filter((cap) => ( + defaults.includes(cap) && stored.includes(cap) + )) +} + +function sameGrantSet( + a: readonly string[], + b: readonly string[], +): boolean { + if (a.length !== b.length) return false + const other = new Set(b) + return a.every((item) => other.has(item)) +} + +export function buildKnowledgeBasePermissionsPayload( + ids: readonly string[], + perms: KnowledgeBasePermissionMap, + capabilities: readonly string[], +): KnowledgeBasePermissionMap { + const defaults = defaultKnowledgeBaseGrants(capabilities) + const out: KnowledgeBasePermissionMap = {} + let customized = false + for (const id of ids) { + const grants = knowledgeBaseGrantsForID(id, ids, perms, capabilities) + out[id] = [...grants] + if (!sameGrantSet(grants, defaults)) customized = true + } + return customized ? out : {} +} + +export function summarizeKnowledgeBasePermissions( + ids: readonly string[], + perms: KnowledgeBasePermissionMap | null | undefined, + capabilities: readonly string[] = [], +): { total: number; editable: number; manageable: number } { + const normalizedIDs = normalizeAPIKeyKnowledgeBaseIDs(ids) + const normalizedPerms = normalizeKnowledgeBasePermissions(perms) + let editable = 0 + let manageable = 0 + for (const id of normalizedIDs) { + const grants = knowledgeBaseGrantsForID(id, normalizedIDs, normalizedPerms, capabilities) + if (grants.includes('ingest')) editable += 1 + if (grants.includes('manage_kbs')) manageable += 1 + } + return { total: normalizedIDs.length, editable, manageable } +} diff --git a/frontend/src/views/knowledge/KnowledgeBaseList.vue b/frontend/src/views/knowledge/KnowledgeBaseList.vue index 0da588be9c7..1f4e0637f11 100644 --- a/frontend/src/views/knowledge/KnowledgeBaseList.vue +++ b/frontend/src/views/knowledge/KnowledgeBaseList.vue @@ -867,6 +867,11 @@ interface KB { creator_id?: string; // creator_name 由后端 list 接口回填,仅用于卡片右下角来源徽章的 tooltip。 creator_name?: string; + permission?: { + read?: boolean; + write?: boolean; + manage?: boolean; + }; } const kbs = ref([]) @@ -1058,8 +1063,8 @@ const recentsList = computed(() => { // 即便我在本空间是 owner 也确实改不动那个 KB;反过来跨空间拿到 editor 的, // 哪怕我在本空间是 contributor 也确实能改。 const EDITABLE_PERMS = new Set(['admin', 'editor']) -function isSharedKbEditable(perm: string | undefined): boolean { - return !!perm && EDITABLE_PERMS.has(perm) +function isSharedKbEditable(perm: unknown): boolean { + return typeof perm === 'string' && EDITABLE_PERMS.has(perm) } // 是否在共享区展示「可编辑 / 仅查看」二级分组:仅对中间档(contributor / editor) @@ -1112,12 +1117,12 @@ const toggleKbSection = (key: KbSectionKey) => { // 输入有两种形态: // 1. filteredKnowledgeBases 的元素,会显式带 `isMine` 标志(见 // filteredKnowledgeBases 里的 spread;跨空间 shared 拆给 isMine=false)。 -// 2. sortedMineKbs 的元素就是原始 KB,无 isMine、也无 permission 字段。 -// 跨空间共享条目一定带 `permission`,本空间条目永远没有,所以"无 permission" -// 是本空间的安全标识。综合:先看 isMine,再回退到 permission 是否存在。 +// 2. sortedMineKbs 的元素就是原始 KB,无 isMine。 +// 跨空间共享条目的 permission 是字符串角色(viewer/editor/admin)。 +// 本空间列表现在也会带 {read,write,manage} 对象,不能再用「有 permission」当共享标识。 const kbSectionOf = (kb: any): KbSectionKey => { if (kb?.is_pinned) return 'pinned' - const isOwnTenant = kb?.isMine === true || (kb?.isMine !== false && kb?.permission == null) + const isOwnTenant = kb?.isMine === true || (kb?.isMine !== false && typeof kb?.permission !== 'string') if (isOwnTenant) return isMyKb(kb) ? 'mine' : 'tenantOthers' return isSharedKbEditable(kb?.permission) ? 'sharedEditable' : 'sharedReadonly' } diff --git a/internal/application/repository/tenant_api_key.go b/internal/application/repository/tenant_api_key.go index 9cd4241d753..72619707b84 100644 --- a/internal/application/repository/tenant_api_key.go +++ b/internal/application/repository/tenant_api_key.go @@ -66,11 +66,12 @@ func (r *tenantAPIKeyRepository) UpdateAPIKey( Where("id = ? AND tenant_id = ? AND scope_type = ? AND revoked_at IS NULL", id, tenantID, types.APIKeyScopeTenant). Updates(map[string]any{ - "name": update.Name, - "full_access": update.FullAccess, - "knowledge_base_ids": update.KnowledgeBaseIDs, - "capabilities": update.Capabilities, - "expires_at": update.ExpiresAt, + "name": update.Name, + "full_access": update.FullAccess, + "knowledge_base_ids": update.KnowledgeBaseIDs, + "knowledge_base_permissions": update.KnowledgeBasePermissions, + "capabilities": update.Capabilities, + "expires_at": update.ExpiresAt, }) if res.Error != nil { return nil, res.Error diff --git a/internal/application/repository/tenant_api_key_test.go b/internal/application/repository/tenant_api_key_test.go index c4653003bd4..212a351e0c4 100644 --- a/internal/application/repository/tenant_api_key_test.go +++ b/internal/application/repository/tenant_api_key_test.go @@ -63,13 +63,17 @@ func TestTenantAPIKeyRepositoryUpdateIsTenantScoped(t *testing.T) { updated, err := repo.UpdateAPIKey(ctx, tenant42, keys[0].ID, &types.TenantAPIKey{ Name: "updated", FullAccess: false, KnowledgeBaseIDs: types.StringArray{"kb-1", "kb-2"}, - Capabilities: types.StringArray{"retrieve", "chat"}, - ExpiresAt: &expiresAt, + KnowledgeBasePermissions: types.KnowledgeBasePermissionMap{ + "kb-1": types.StringArray{"retrieve"}, + }, + Capabilities: types.StringArray{"retrieve", "chat"}, + ExpiresAt: &expiresAt, }) require.NoError(t, err) require.Equal(t, "updated", updated.Name) require.Equal(t, types.StringArray{"kb-1", "kb-2"}, updated.KnowledgeBaseIDs) require.Equal(t, types.StringArray{"retrieve", "chat"}, updated.Capabilities) + require.Equal(t, types.KnowledgeBasePermissionMap{"kb-1": types.StringArray{"retrieve"}}, updated.KnowledgeBasePermissions) require.NotNil(t, updated.ExpiresAt) require.True(t, updated.ExpiresAt.Equal(expiresAt)) diff --git a/internal/application/service/tenant_api_key.go b/internal/application/service/tenant_api_key.go index 09a5243da7b..2850640053c 100644 --- a/internal/application/service/tenant_api_key.go +++ b/internal/application/service/tenant_api_key.go @@ -62,19 +62,27 @@ func (s *tenantAPIKeyService) CreateAPIKey( if scopeType == types.APIKeyScopeTenant { tenantID = &req.TenantID } + ids, perms := types.NormalizeKnowledgeBasePermissions( + types.StringArray(req.KnowledgeBaseIDs), + req.KnowledgeBasePermissions, + capabilities, + req.FullAccess, + ) key := &types.TenantAPIKey{ - TenantID: tenantID, - ScopeType: scopeType, - Name: name, - KeyHash: hashTenantAPIKey(token), - APIKey: token, - FullAccess: req.FullAccess, - KnowledgeBaseIDs: normalizeAPIKeyIDs(req.KnowledgeBaseIDs), - Capabilities: capabilities, - ExpiresAt: expiresAt, + TenantID: tenantID, + ScopeType: scopeType, + Name: name, + KeyHash: hashTenantAPIKey(token), + APIKey: token, + FullAccess: req.FullAccess, + KnowledgeBaseIDs: ids, + KnowledgeBasePermissions: perms, + Capabilities: capabilities, + ExpiresAt: expiresAt, } if key.FullAccess { key.KnowledgeBaseIDs = nil + key.KnowledgeBasePermissions = nil key.Capabilities = nil } if err := s.repo.CreateAPIKey(ctx, key); err != nil { @@ -154,15 +162,23 @@ func (s *tenantAPIKeyService) UpdateAPIKey( utc := expiresAt.UTC() expiresAt = &utc } + ids, perms := types.NormalizeKnowledgeBasePermissions( + types.StringArray(req.KnowledgeBaseIDs), + req.KnowledgeBasePermissions, + capabilities, + req.FullAccess, + ) key := &types.TenantAPIKey{ - Name: name, - FullAccess: req.FullAccess, - KnowledgeBaseIDs: normalizeAPIKeyIDs(req.KnowledgeBaseIDs), - Capabilities: capabilities, - ExpiresAt: expiresAt, + Name: name, + FullAccess: req.FullAccess, + KnowledgeBaseIDs: ids, + KnowledgeBasePermissions: perms, + Capabilities: capabilities, + ExpiresAt: expiresAt, } if key.FullAccess { key.KnowledgeBaseIDs = nil + key.KnowledgeBasePermissions = nil key.Capabilities = nil } return s.repo.UpdateAPIKey(ctx, req.TenantID, req.APIKeyID, key) @@ -217,20 +233,3 @@ func hashTenantAPIKey(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } - -func normalizeAPIKeyIDs(in []string) types.StringArray { - out := types.StringArray{} - seen := map[string]struct{}{} - for _, id := range in { - id = strings.TrimSpace(id) - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} diff --git a/internal/application/service/tenant_api_key_test.go b/internal/application/service/tenant_api_key_test.go index ba9f56f9346..20516f37d46 100644 --- a/internal/application/service/tenant_api_key_test.go +++ b/internal/application/service/tenant_api_key_test.go @@ -106,6 +106,7 @@ func (r *fakeTenantAPIKeyRepo) UpdateAPIKey( key.Name = update.Name key.FullAccess = update.FullAccess key.KnowledgeBaseIDs = append(types.StringArray(nil), update.KnowledgeBaseIDs...) + key.KnowledgeBasePermissions = cloneKnowledgeBasePermissions(update.KnowledgeBasePermissions) key.Capabilities = append(types.StringArray(nil), update.Capabilities...) key.ExpiresAt = update.ExpiresAt cp := *key @@ -158,12 +159,63 @@ func TestTenantAPIKeyServiceUpdateNormalizesConfiguration(t *testing.T) { if err != nil { t.Fatalf("updating to full access returned error: %v", err) } - if !full.FullAccess || len(full.KnowledgeBaseIDs) != 0 || len(full.Capabilities) != 0 { - t.Fatalf("full access scope = full:%v kbs:%v caps:%v, want true/empty/empty", - full.FullAccess, full.KnowledgeBaseIDs, full.Capabilities) + if !full.FullAccess || len(full.KnowledgeBaseIDs) != 0 || len(full.Capabilities) != 0 || len(full.KnowledgeBasePermissions) != 0 { + t.Fatalf("full access scope = full:%v kbs:%v caps:%v perms:%v, want true/empty/empty/empty", + full.FullAccess, full.KnowledgeBaseIDs, full.Capabilities, full.KnowledgeBasePermissions) } } +func TestTenantAPIKeyServicePersistsKnowledgeBasePermissions(t *testing.T) { + ctx := context.Background() + repo := newFakeTenantAPIKeyRepo() + svc := NewTenantAPIKeyService(repo) + created, err := svc.CreateAPIKey(ctx, interfaces.TenantAPIKeyCreateRequest{ + TenantID: 42, + Name: "scoped", + Capabilities: []string{"retrieve", "ingest", "manage_kbs"}, + KnowledgeBaseIDs: []string{"kb-1", "kb-2"}, + KnowledgeBasePermissions: types.KnowledgeBasePermissionMap{ + "kb-1": types.StringArray{"retrieve"}, + "kb-2": types.StringArray{"retrieve", "ingest", "manage_kbs"}, + }, + }) + if err != nil { + t.Fatalf("CreateAPIKey returned error: %v", err) + } + if got := []string(created.APIKey.KnowledgeBasePermissions["kb-1"]); !apiKeyEqualStrings(got, []string{"retrieve"}) { + t.Fatalf("kb-1 grants = %#v, want [retrieve]", got) + } + + updated, err := svc.UpdateAPIKey(ctx, interfaces.TenantAPIKeyUpdateRequest{ + TenantID: 42, APIKeyID: created.APIKey.ID, Name: "scoped", + Capabilities: []string{"retrieve", "ingest"}, + KnowledgeBaseIDs: []string{"kb-1", "kb-2"}, + KnowledgeBasePermissions: types.KnowledgeBasePermissionMap{ + "kb-1": types.StringArray{"retrieve", "ingest"}, + }, + }) + if err != nil { + t.Fatalf("UpdateAPIKey returned error: %v", err) + } + if _, ok := updated.KnowledgeBasePermissions["kb-2"]; ok { + t.Fatal("kb-2 should inherit after its overlay entry is omitted") + } + if got := []string(updated.KnowledgeBasePermissions["kb-1"]); !apiKeyEqualStrings(got, []string{"retrieve", "ingest"}) { + t.Fatalf("kb-1 grants = %#v, want [retrieve ingest]", got) + } +} + +func cloneKnowledgeBasePermissions(in types.KnowledgeBasePermissionMap) types.KnowledgeBasePermissionMap { + if len(in) == 0 { + return types.KnowledgeBasePermissionMap{} + } + out := types.KnowledgeBasePermissionMap{} + for kbID, grants := range in { + out[kbID] = append(types.StringArray(nil), grants...) + } + return out +} + func apiKeyEqualStrings(a, b []string) bool { if len(a) != len(b) { return false diff --git a/internal/database/migration_sqlite_versioned_schema_test.go b/internal/database/migration_sqlite_versioned_schema_test.go index 8dee622d533..1126d4a8045 100644 --- a/internal/database/migration_sqlite_versioned_schema_test.go +++ b/internal/database/migration_sqlite_versioned_schema_test.go @@ -38,9 +38,10 @@ var versionedSQLiteColumns = map[string][]string{ "embed_channels": {"allow_memory"}, // 000060 "mcp_oauth_tokens": {"principal_type", "principal_id"}, // 000064 "mcp_tool_approvals": {"enabled"}, // 000091 + "tenant_api_keys": {"knowledge_base_permissions"}, // 000096 } -const expectedSQLiteMigrationVersion = 16 +const expectedSQLiteMigrationVersion = 17 func TestSQLiteMigrationsCreateVersionedSchema(t *testing.T) { repoRoot := sqliteRepoRoot(t) diff --git a/internal/handler/knowledgebase.go b/internal/handler/knowledgebase.go index 30c12107d0e..e2f5ccf9e69 100644 --- a/internal/handler/knowledgebase.go +++ b/internal/handler/knowledgebase.go @@ -175,11 +175,21 @@ func (h *KnowledgeBaseHandler) buildKBListResponse( view = v } } - out = append(out, buildKBResponse(kb, view, nil)) + out = append(out, buildKBResponse(kb, view, withKBPermission(ctx, kb, callerTenantID, nil))) } return out } +// withKBPermission stamps the caller's per-KB read/write/manage flags onto +// the response extras, next to id / name / capabilities. +func withKBPermission(ctx context.Context, kb *types.KnowledgeBase, callerTenantID uint64, extras map[string]interface{}) map[string]interface{} { + if extras == nil { + extras = make(map[string]interface{}, 1) + } + extras["permission"] = types.CallerKnowledgeBasePermission(ctx, kb, callerTenantID) + return extras +} + // sharedKBRow projects a SharedKnowledgeBaseInfo into a response payload // that respects the cross-tenant strip rule: the embedded KnowledgeBase // row runs through buildKBResponse with SharedStoreDisplay() so its @@ -441,7 +451,7 @@ func (h *KnowledgeBaseHandler) CreateKnowledgeBase(c *gin.Context) { callerTenantID := c.GetUint64(types.TenantIDContextKey.String()) c.JSON(http.StatusCreated, gin.H{ "success": true, - "data": buildKBResponse(kb, h.resolveKBStoreView(ctx, kb, callerTenantID), nil), + "data": buildKBResponse(kb, h.resolveKBStoreView(ctx, kb, callerTenantID), withKBPermission(ctx, kb, callerTenantID, nil)), }) } @@ -493,7 +503,7 @@ func (h *KnowledgeBaseHandler) GetKnowledgeBase(c *gin.Context) { // Include my_permission in data so frontend can show role (e.g. "只读") instead of "--" for agent-visible KBs extras = map[string]interface{}{"my_permission": permission} } - c.JSON(http.StatusOK, gin.H{"success": true, "data": buildKBResponse(kb, storeView, extras)}) + c.JSON(http.StatusOK, gin.H{"success": true, "data": buildKBResponse(kb, storeView, withKBPermission(c.Request.Context(), kb, tenantID, extras))}) } // ListKnowledgeBases godoc @@ -704,11 +714,10 @@ func (h *KnowledgeBaseHandler) TogglePinKnowledgeBase(c *gin.Context) { callerTenantID := c.GetUint64(types.TenantIDContextKey.String()) c.JSON(http.StatusOK, gin.H{ "success": true, - "data": buildKBResponse(kb, h.resolveKBStoreView(ctx, kb, callerTenantID), nil), + "data": buildKBResponse(kb, h.resolveKBStoreView(ctx, kb, callerTenantID), withKBPermission(ctx, kb, callerTenantID, nil)), }) } -// UpdateKnowledgeBaseRequest defines the request body structure for updating a knowledge base type UpdateKnowledgeBaseRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` @@ -780,11 +789,10 @@ func (h *KnowledgeBaseHandler) UpdateKnowledgeBase(c *gin.Context) { callerTenantID := c.GetUint64(types.TenantIDContextKey.String()) c.JSON(http.StatusOK, gin.H{ "success": true, - "data": buildKBResponse(kb, h.resolveKBStoreView(ctx, kb, callerTenantID), nil), + "data": buildKBResponse(kb, h.resolveKBStoreView(ctx, kb, callerTenantID), withKBPermission(ctx, kb, callerTenantID, nil)), }) } -// DeleteKnowledgeBase godoc // @Summary 删除知识库 // @Description 删除指定的知识库及其所有内容 // @Tags 知识库 @@ -885,6 +893,12 @@ func (h *KnowledgeBaseHandler) CopyKnowledgeBase(c *gin.Context) { c.Error(err) return } + if req.TargetID != "" { + if err := types.AuthorizeTenantAPIKeyKnowledgeBaseCapability(ctx, req.TargetID, types.APIKeyCapabilityManageKnowledgeBases); err != nil { + c.Error(err) + return + } + } sourceKB := sourceGrant.KnowledgeBase if sourceKB.TenantID != caller.TenantID { _ = c.Error(errors.NewForbiddenError("No permission to copy this knowledge base")) @@ -1066,7 +1080,7 @@ func (h *KnowledgeBaseHandler) DuplicateKnowledgeBase(c *gin.Context) { SourceID: sourceID, TargetID: targetKB.ID, Message: "Knowledge base duplicate created", - KnowledgeBase: buildKBResponse(targetKB, h.resolveKBStoreView(ctx, targetKB, callerTenantID), nil), + KnowledgeBase: buildKBResponse(targetKB, h.resolveKBStoreView(ctx, targetKB, callerTenantID), withKBPermission(ctx, targetKB, callerTenantID, nil)), }, }) } diff --git a/internal/handler/knowledgebase_pr3_response_test.go b/internal/handler/knowledgebase_pr3_response_test.go index d6ea974405c..d226a22947c 100644 --- a/internal/handler/knowledgebase_pr3_response_test.go +++ b/internal/handler/knowledgebase_pr3_response_test.go @@ -186,3 +186,36 @@ func TestBuildKBResponse_KeepsVectorStoreIDForOwnerKB(t *testing.T) { t.Fatalf("owner KB must surface store name, got %v", m["vector_store_name"]) } } + +func TestBuildKBResponse_IncludesCallerPermission(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Capabilities: types.StringArray{"retrieve", "ingest", "manage_kbs"}, + KnowledgeBaseIDs: types.StringArray{"kb-1", "kb-2"}, + KnowledgeBasePermissions: types.KnowledgeBasePermissionMap{ + "kb-1": types.StringArray{"retrieve"}, + "kb-2": types.StringArray{"retrieve", "ingest", "manage_kbs"}, + }, + }) + readOnly := buildKBResponse(&types.KnowledgeBase{ID: "kb-1", Name: "a", TenantID: 1}, types.DefaultStoreDisplay(), withKBPermission(ctx, &types.KnowledgeBase{ID: "kb-1", TenantID: 1}, 1, nil)) + m, ok := readOnly.(map[string]interface{}) + if !ok { + t.Fatalf("expected map result, got %T", readOnly) + } + perm, ok := m["permission"].(types.KnowledgeBaseAccess) + if !ok { + t.Fatalf("expected permission object, got %T %v", m["permission"], m["permission"]) + } + if !perm.Read || perm.Write || perm.Manage { + t.Fatalf("kb-1 permission: %+v", perm) + } + if _, ok := m["capabilities"]; !ok { + t.Fatal("expected capabilities to remain on the KB payload") + } + + full := buildKBResponse(&types.KnowledgeBase{ID: "kb-2", Name: "b", TenantID: 1}, types.DefaultStoreDisplay(), withKBPermission(ctx, &types.KnowledgeBase{ID: "kb-2", TenantID: 1}, 1, nil)) + fm := full.(map[string]interface{}) + fp := fm["permission"].(types.KnowledgeBaseAccess) + if !fp.Read || !fp.Write || !fp.Manage { + t.Fatalf("kb-2 permission: %+v", fp) + } +} diff --git a/internal/handler/tenant.go b/internal/handler/tenant.go index 5467bc0aee9..07e1f535fc1 100644 --- a/internal/handler/tenant.go +++ b/internal/handler/tenant.go @@ -136,33 +136,36 @@ type apiPrincipalTestTokenResponse struct { } type tenantAPIKeyCreateRequest struct { - Name string `json:"name"` - FullAccess bool `json:"full_access"` - KnowledgeBaseIDs []string `json:"knowledge_base_ids"` - Capabilities []string `json:"capabilities"` - ExpiresAt *int64 `json:"expires_at_unix"` + Name string `json:"name"` + FullAccess bool `json:"full_access"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + KnowledgeBasePermissions types.KnowledgeBasePermissionMap `json:"knowledge_base_permissions"` + Capabilities []string `json:"capabilities"` + ExpiresAt *int64 `json:"expires_at_unix"` } // tenantAPIKeyUpdateRequest 修改已创建 API Key 的配置,字段语义与创建接口一致。 type tenantAPIKeyUpdateRequest struct { - Name string `json:"name"` - FullAccess bool `json:"full_access"` - KnowledgeBaseIDs []string `json:"knowledge_base_ids"` - Capabilities []string `json:"capabilities"` - ExpiresAt *int64 `json:"expires_at_unix"` + Name string `json:"name"` + FullAccess bool `json:"full_access"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + KnowledgeBasePermissions types.KnowledgeBasePermissionMap `json:"knowledge_base_permissions"` + Capabilities []string `json:"capabilities"` + ExpiresAt *int64 `json:"expires_at_unix"` } type tenantAPIKeyResponse struct { - 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"` + 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"` + KnowledgeBasePermissions types.KnowledgeBasePermissionMap `json:"knowledge_base_permissions"` + 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 { @@ -702,12 +705,13 @@ func (h *TenantHandler) CreateAPIKey(c *gin.Context) { expiresAt = &t } result, err := h.apiKeyService.CreateAPIKey(ctx, interfaces.TenantAPIKeyCreateRequest{ - TenantID: id, - Name: req.Name, - FullAccess: req.FullAccess, - KnowledgeBaseIDs: req.KnowledgeBaseIDs, - Capabilities: req.Capabilities, - ExpiresAt: expiresAt, + TenantID: id, + Name: req.Name, + FullAccess: req.FullAccess, + KnowledgeBaseIDs: req.KnowledgeBaseIDs, + KnowledgeBasePermissions: req.KnowledgeBasePermissions, + Capabilities: req.Capabilities, + ExpiresAt: expiresAt, }) if err != nil { c.Error(errors.NewInternalServerError("Failed to create API key").WithDetails(err.Error())) @@ -753,7 +757,8 @@ func (h *TenantHandler) UpdateAPIKey(c *gin.Context) { updated, err := h.apiKeyService.UpdateAPIKey(ctx, interfaces.TenantAPIKeyUpdateRequest{ TenantID: tenantID, APIKeyID: keyID, Name: req.Name, FullAccess: req.FullAccess, - KnowledgeBaseIDs: req.KnowledgeBaseIDs, Capabilities: req.Capabilities, ExpiresAt: expiresAt, + KnowledgeBaseIDs: req.KnowledgeBaseIDs, KnowledgeBasePermissions: req.KnowledgeBasePermissions, + Capabilities: req.Capabilities, ExpiresAt: expiresAt, }) if err != nil { c.Error(errors.NewNotFoundError("API key not found")) @@ -783,19 +788,24 @@ func (h *TenantHandler) DeleteAPIKey(c *gin.Context) { func tenantAPIKeyForResponse(key *types.TenantAPIKey) tenantAPIKeyResponse { if key == nil { - return tenantAPIKeyResponse{} + return tenantAPIKeyResponse{KnowledgeBasePermissions: types.KnowledgeBasePermissionMap{}} + } + permissions := key.KnowledgeBasePermissions + if permissions == nil { + permissions = types.KnowledgeBasePermissionMap{} } return tenantAPIKeyResponse{ - ID: key.ID, - ScopeType: types.NormalizeAPIKeyScopeType(key.ScopeType), - Name: key.Name, - APIKey: key.APIKey, - FullAccess: key.FullAccess, - KnowledgeBaseIDs: key.KnowledgeBaseIDs, - Capabilities: types.NormalizeAPIKeyCapabilities(key.Capabilities), - LastUsedAt: key.LastUsedAt, - ExpiresAt: key.ExpiresAt, - CreatedAt: key.CreatedAt, + ID: key.ID, + ScopeType: types.NormalizeAPIKeyScopeType(key.ScopeType), + Name: key.Name, + APIKey: key.APIKey, + FullAccess: key.FullAccess, + KnowledgeBaseIDs: key.KnowledgeBaseIDs, + KnowledgeBasePermissions: permissions, + Capabilities: types.NormalizeAPIKeyCapabilities(key.Capabilities), + LastUsedAt: key.LastUsedAt, + ExpiresAt: key.ExpiresAt, + CreatedAt: key.CreatedAt, } } @@ -823,6 +833,9 @@ func validateTenantAPIKeyRequest( return errors.NewValidationError("capabilities contains an unknown capability") } } + if types.KnowledgeBasePermissionsExceedCeiling(req.KnowledgeBasePermissions, caps) { + return errors.NewValidationError("knowledge_base_permissions exceeds the key capabilities") + } return validateTenantAPIKeyKnowledgeBaseIDs(ctx, kbService, tenantID, req.KnowledgeBaseIDs) } diff --git a/internal/handler/tenant_api_key_validation_test.go b/internal/handler/tenant_api_key_validation_test.go index 020cfd777f7..2ec6dda68c6 100644 --- a/internal/handler/tenant_api_key_validation_test.go +++ b/internal/handler/tenant_api_key_validation_test.go @@ -60,3 +60,17 @@ func TestValidateTenantAPIKeyKnowledgeBaseOwnership(t *testing.T) { t.Fatal("expected missing knowledge base to be rejected") } } + +func TestValidateTenantAPIKeyRequestRejectsPermissionsAboveCeiling(t *testing.T) { + err := validateTenantAPIKeyRequest(context.Background(), nil, 1, tenantAPIKeyCreateRequest{ + Name: "integration", + Capabilities: []string{"retrieve"}, + KnowledgeBaseIDs: []string{"kb-1"}, + KnowledgeBasePermissions: types.KnowledgeBasePermissionMap{ + "kb-1": types.StringArray{"retrieve", "ingest"}, + }, + }) + if err == nil { + t.Fatal("expected validation error when per-KB ingest exceeds global capabilities") + } +} diff --git a/internal/middleware/api_key_gate.go b/internal/middleware/api_key_gate.go index 3963e80c556..151c2300114 100644 --- a/internal/middleware/api_key_gate.go +++ b/internal/middleware/api_key_gate.go @@ -11,6 +11,23 @@ import ( var errTenantAPIKeyScopeForbidden = stderrors.New("workspace API key scope forbidden") +// APIKeyRoutePolicyContextKey stashes the matched route policy on gin.Context +// so downstream KB guards can map read/write/manage onto per-KB overlays. +const APIKeyRoutePolicyContextKey = "rbac.api_key_route_policy" + +// APIKeyRoutePolicyFromContext returns the policy stored by APIKeyGate. +func APIKeyRoutePolicyFromContext(c *gin.Context) (APIKeyRoutePolicy, bool) { + if c == nil { + return APIKeyRoutePolicy{}, false + } + v, ok := c.Get(APIKeyRoutePolicyContextKey) + if !ok { + return APIKeyRoutePolicy{}, false + } + policy, ok := v.(APIKeyRoutePolicy) + return policy, ok +} + // APIKeyRoutePolicy declares how an X-API-Key caller may use a single route. // // Design: API-key authorization is a SEPARATE authority from the JWT @@ -121,6 +138,9 @@ func (a *APIKeyRouteAuthorizer) Middleware() gin.HandlerFunc { }) return } + if policy, ok := a.Lookup(c.Request.Method, c.FullPath()); ok { + c.Set(APIKeyRoutePolicyContextKey, policy) + } c.Next() } } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index f9ac6f6a2f1..7f553ec336b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -548,11 +548,12 @@ func attachAPIKeyAuthContext( } if key != nil { session.APIKeyScope = &types.TenantAPIKeyScope{ - KeyID: key.ID, - ScopeType: key.ScopeType, - FullAccess: fullAccess, - KnowledgeBaseIDs: key.KnowledgeBaseIDs, - Capabilities: key.Capabilities, + KeyID: key.ID, + ScopeType: key.ScopeType, + FullAccess: fullAccess, + KnowledgeBaseIDs: key.KnowledgeBaseIDs, + KnowledgeBasePermissions: key.KnowledgeBasePermissions, + Capabilities: key.Capabilities, } } applyAuthSession(c, session) diff --git a/internal/middleware/kb_access.go b/internal/middleware/kb_access.go index 456d5531225..9b1dd9ab028 100644 --- a/internal/middleware/kb_access.go +++ b/internal/middleware/kb_access.go @@ -252,6 +252,18 @@ func RequireKBAccess( return } + if err := authorizeAPIKeyKBCapability(ctx, c, kbID, requiredPermission); err != nil { + if !enforcing { + logger.Warnf(ctx, "[rbac] kb-access would 403 api-key overlay (enforcement off): kb=%s required=%s", + kbID, requiredPermission) + c.Next() + return + } + _ = c.Error(err) + c.Abort() + return + } + // Stash the resolution and rewrite the request to carry the // effective tenant id. Handlers reading tenant from context now // see the source-tenant for shared KBs (so retrieval queries @@ -308,3 +320,48 @@ func resolveKBAccess( } return access.ResolveKB(ctx, request, kb, requiredPermission, kbShareService, agentShareService) } + +func authorizeAPIKeyKBCapability( + ctx context.Context, + c *gin.Context, + kbID string, + requiredPermission types.OrgMemberRole, +) error { + caps := apiKeyKBCapabilitiesForAccess(requiredPermission, c) + if len(caps) == 0 { + return nil + } + return types.AuthorizeTenantAPIKeyKnowledgeBaseAnyCapability(ctx, kbID, caps...) +} + +func apiKeyKBCapabilitiesForAccess(required types.OrgMemberRole, c *gin.Context) []types.APIKeyCapability { + if required == types.OrgRoleViewer { + return []types.APIKeyCapability{types.APIKeyCapabilityRetrieve} + } + policy, hasPolicy := APIKeyRoutePolicyFromContext(c) + if hasPolicy { + hasIngest := routePolicyHasCapability(policy, types.APIKeyCapabilityIngest) + hasManage := routePolicyHasCapability(policy, types.APIKeyCapabilityManageKnowledgeBases) + switch { + case hasManage && !hasIngest: + return []types.APIKeyCapability{types.APIKeyCapabilityManageKnowledgeBases} + case hasIngest && !hasManage: + return []types.APIKeyCapability{types.APIKeyCapabilityIngest} + case hasManage && hasIngest: + return []types.APIKeyCapability{types.APIKeyCapabilityIngest, types.APIKeyCapabilityManageKnowledgeBases} + } + } + if required == types.OrgRoleAdmin { + return []types.APIKeyCapability{types.APIKeyCapabilityManageKnowledgeBases} + } + return []types.APIKeyCapability{types.APIKeyCapabilityIngest} +} + +func routePolicyHasCapability(policy APIKeyRoutePolicy, cap types.APIKeyCapability) bool { + for _, existing := range policy.Capabilities { + if existing == cap { + return true + } + } + return false +} diff --git a/internal/middleware/kb_access_test.go b/internal/middleware/kb_access_test.go index 57e72203477..974cd256b25 100644 --- a/internal/middleware/kb_access_test.go +++ b/internal/middleware/kb_access_test.go @@ -180,6 +180,8 @@ type guardOpts struct { agentID string // ?agent_id query param agentSourceTenantID string // ?agent_source_tenant_id query param agentShare *stubAgentShareForGuard // nil means "no agent-share service" + apiKeyScope *types.TenantAPIKeyScope + routePolicy *APIKeyRoutePolicy } // runGuard fires a single request through the guard and returns the @@ -214,7 +216,13 @@ func runGuard( } req := httptest.NewRequest("GET", url, nil) ctx := context.WithValue(req.Context(), types.TenantIDContextKey, tenantID) + if opts.apiKeyScope != nil { + ctx = types.WithTenantAPIKeyScope(ctx, *opts.apiKeyScope) + } c.Request = req.WithContext(ctx) + if opts.routePolicy != nil { + c.Set(APIKeyRoutePolicyContextKey, *opts.routePolicy) + } kbsvc := &stubKBLookup{kbs: map[string]*types.KnowledgeBase{}} if kb != nil { @@ -265,6 +273,64 @@ func TestRequireKBAccess_OwnKB(t *testing.T) { require.Equal(t, uint64(100), got) } +func TestRequireKBAccess_APIKeyOwnKB_IngestOverlayForbidden(t *testing.T) { + scope := types.TenantAPIKeyScope{ + KnowledgeBaseIDs: types.StringArray{"kb-1"}, + Capabilities: types.StringArray{"retrieve", "ingest"}, + KnowledgeBasePermissions: types.KnowledgeBasePermissionMap{ + "kb-1": types.StringArray{"retrieve"}, + }, + } + _, c := runGuard(t, 100, "kb-1", + types.OrgRoleEditor, + &types.KnowledgeBase{ID: "kb-1", TenantID: 100}, + nil, + guardOpts{apiKeyScope: &scope}, + ) + require.True(t, c.IsAborted(), "own KB must not skip a retrieve-only overlay on write routes") +} + +func TestRequireKBAccess_APIKeyOwnKB_EmptyOverlayInheritsIngest(t *testing.T) { + scope := types.TenantAPIKeyScope{ + KnowledgeBaseIDs: types.StringArray{"kb-1"}, + Capabilities: types.StringArray{"retrieve", "ingest"}, + } + _, c := runGuard(t, 100, "kb-1", + types.OrgRoleEditor, + &types.KnowledgeBase{ID: "kb-1", TenantID: 100}, + nil, + guardOpts{apiKeyScope: &scope}, + ) + require.False(t, c.IsAborted(), "empty overlay should inherit ingest") +} + +func TestRequireKBAccess_APIKeyOwnKB_ManageWithoutIngest(t *testing.T) { + scope := types.TenantAPIKeyScope{ + KnowledgeBaseIDs: types.StringArray{"kb-1"}, + Capabilities: types.StringArray{"retrieve", "ingest", "manage_kbs"}, + KnowledgeBasePermissions: types.KnowledgeBasePermissionMap{ + "kb-1": types.StringArray{"retrieve", "manage_kbs"}, + }, + } + ingestPolicy := APIKeyRoutePolicy{}.WithCapability(types.APIKeyCapabilityIngest) + _, write := runGuard(t, 100, "kb-1", + types.OrgRoleEditor, + &types.KnowledgeBase{ID: "kb-1", TenantID: 100}, + nil, + guardOpts{apiKeyScope: &scope, routePolicy: &ingestPolicy}, + ) + require.True(t, write.IsAborted(), "ingest route must fail when the overlay dropped ingest") + + managePolicy := APIKeyRoutePolicy{}.WithCapability(types.APIKeyCapabilityManageKnowledgeBases) + _, manage := runGuard(t, 100, "kb-1", + types.OrgRoleEditor, + &types.KnowledgeBase{ID: "kb-1", TenantID: 100}, + nil, + guardOpts{apiKeyScope: &scope, routePolicy: &managePolicy}, + ) + require.False(t, manage.IsAborted(), "manage_kbs route should pass a manage overlay") +} + // TestIsResourceNotFound_RecognisesKnowledgeSentinel pins that a missing // *document* (knowledge) is treated as not-found, not a transient error. // Regression: ErrKnowledgeNotFound was absent from the predicate, so diff --git a/internal/types/interfaces/tenant.go b/internal/types/interfaces/tenant.go index 5beea60d874..db477a008b0 100644 --- a/internal/types/interfaces/tenant.go +++ b/internal/types/interfaces/tenant.go @@ -63,13 +63,14 @@ type TenantRepository interface { } type TenantAPIKeyCreateRequest struct { - TenantID uint64 - ScopeType types.APIKeyScopeType - Name string - FullAccess bool - KnowledgeBaseIDs []string - Capabilities []string - ExpiresAt *time.Time + TenantID uint64 + ScopeType types.APIKeyScopeType + Name string + FullAccess bool + KnowledgeBaseIDs []string + KnowledgeBasePermissions types.KnowledgeBasePermissionMap + Capabilities []string + ExpiresAt *time.Time } type TenantAPIKeyCreateResult struct { @@ -80,13 +81,14 @@ type TenantAPIKeyCreateResult struct { // TenantAPIKeyUpdateRequest 修改已创建租户 API Key 的可配置属性。 // 配置语义与创建接口一致:FullAccess 为 true 时忽略细粒度能力和知识库范围。 type TenantAPIKeyUpdateRequest struct { - TenantID uint64 - APIKeyID uint64 - Name string - FullAccess bool - KnowledgeBaseIDs []string - Capabilities []string - ExpiresAt *time.Time + TenantID uint64 + APIKeyID uint64 + Name string + FullAccess bool + KnowledgeBaseIDs []string + KnowledgeBasePermissions types.KnowledgeBasePermissionMap + Capabilities []string + ExpiresAt *time.Time } type TenantAPIKeyRepository interface { diff --git a/internal/types/knowledgebase_access.go b/internal/types/knowledgebase_access.go new file mode 100644 index 00000000000..11e8a75b7b4 --- /dev/null +++ b/internal/types/knowledgebase_access.go @@ -0,0 +1,57 @@ +package types + +import "context" + +// KnowledgeBaseAccess is the caller's read / write / manage rights on one +// knowledge base. It is request-scoped (API-key overlay or tenant RBAC), +// not stored on the knowledge_bases row. +// +// JSON sits next to id / name / capabilities on GET /knowledge-bases: +// +// "permission": { "read": true, "write": false, "manage": false } +// +// Mapping: read = retrieve (or chat), write = ingest, manage = manage_kbs. +type KnowledgeBaseAccess struct { + Read bool `json:"read"` + Write bool `json:"write"` + Manage bool `json:"manage"` +} + +// CallerKnowledgeBasePermission reports what the current request may do to kb. +// An API-key scope always wins over tenant RBAC, including FullAccess keys +// that would otherwise look like TenantRoleOwner. +func CallerKnowledgeBasePermission(ctx context.Context, kb *KnowledgeBase, callerTenantID uint64) KnowledgeBaseAccess { + if kb == nil { + return KnowledgeBaseAccess{} + } + if scope, ok := TenantAPIKeyScopeFromContext(ctx); ok { + return knowledgeBaseAccessFromAPIKey(scope, kb.ID) + } + if callerTenantID != 0 && kb.TenantID != callerTenantID { + return KnowledgeBaseAccess{Read: true} + } + return knowledgeBaseAccessFromTenantRole(ctx, kb) +} + +func knowledgeBaseAccessFromAPIKey(scope TenantAPIKeyScope, kbID string) KnowledgeBaseAccess { + return KnowledgeBaseAccess{ + Read: scope.AllowsKnowledgeBaseCapability(kbID, APIKeyCapabilityRetrieve), + Write: scope.AllowsKnowledgeBaseCapability(kbID, APIKeyCapabilityIngest), + Manage: scope.AllowsKnowledgeBaseCapability(kbID, APIKeyCapabilityManageKnowledgeBases), + } +} + +func knowledgeBaseAccessFromTenantRole(ctx context.Context, kb *KnowledgeBase) KnowledgeBaseAccess { + role := TenantRoleFromContext(ctx) + if role.HasPermission(TenantRoleAdmin) { + return KnowledgeBaseAccess{Read: true, Write: true, Manage: true} + } + if role == TenantRoleContributor { + userID, _ := UserIDFromContext(ctx) + if userID != "" && kb.CreatorID == userID { + return KnowledgeBaseAccess{Read: true, Write: true, Manage: true} + } + return KnowledgeBaseAccess{Read: true} + } + return KnowledgeBaseAccess{Read: true} +} diff --git a/internal/types/knowledgebase_access_test.go b/internal/types/knowledgebase_access_test.go new file mode 100644 index 00000000000..f3aa217b624 --- /dev/null +++ b/internal/types/knowledgebase_access_test.go @@ -0,0 +1,101 @@ +package types + +import ( + "context" + "testing" +) + +func TestCallerKnowledgeBasePermissionAPIKeyPerKBOverlay(t *testing.T) { + ctx := WithTenantAPIKeyScope(context.Background(), TenantAPIKeyScope{ + Capabilities: StringArray{"retrieve", "ingest", "manage_kbs"}, + KnowledgeBaseIDs: StringArray{"kb-read", "kb-write", "kb-manage"}, + KnowledgeBasePermissions: KnowledgeBasePermissionMap{ + "kb-read": StringArray{"retrieve"}, + "kb-write": StringArray{"retrieve", "ingest"}, + "kb-manage": StringArray{"retrieve", "ingest", "manage_kbs"}, + }, + }) + got := CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-read", TenantID: 1}, 1) + if got != (KnowledgeBaseAccess{Read: true, Write: false, Manage: false}) { + t.Fatalf("kb-read: %+v", got) + } + got = CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-write", TenantID: 1}, 1) + if got != (KnowledgeBaseAccess{Read: true, Write: true, Manage: false}) { + t.Fatalf("kb-write: %+v", got) + } + got = CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-manage", TenantID: 1}, 1) + if got != (KnowledgeBaseAccess{Read: true, Write: true, Manage: true}) { + t.Fatalf("kb-manage: %+v", got) + } +} + +func TestCallerKnowledgeBasePermissionAPIKeyInheritsGlobalCaps(t *testing.T) { + ctx := WithTenantAPIKeyScope(context.Background(), TenantAPIKeyScope{ + Capabilities: StringArray{"retrieve", "ingest"}, + KnowledgeBaseIDs: StringArray{"kb-1"}, + }) + got := CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-1", TenantID: 1}, 1) + if got != (KnowledgeBaseAccess{Read: true, Write: true, Manage: false}) { + t.Fatalf("inherit global: %+v", got) + } +} + +func TestCallerKnowledgeBasePermissionAPIKeyFullAccess(t *testing.T) { + ctx := WithTenantAPIKeyScope(context.Background(), TenantAPIKeyScope{FullAccess: true}) + got := CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-1", TenantID: 1}, 1) + if got != (KnowledgeBaseAccess{Read: true, Write: true, Manage: true}) { + t.Fatalf("full access: %+v", got) + } +} + +func TestCallerKnowledgeBasePermissionAPIKeyChatCountsAsRead(t *testing.T) { + ctx := WithTenantAPIKeyScope(context.Background(), TenantAPIKeyScope{ + Capabilities: StringArray{"chat"}, + KnowledgeBaseIDs: StringArray{"kb-1"}, + }) + got := CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-1", TenantID: 1}, 1) + if got != (KnowledgeBaseAccess{Read: true}) { + t.Fatalf("chat-only: %+v", got) + } +} + +func TestCallerKnowledgeBasePermissionAPIKeyWinsOverOwnerRole(t *testing.T) { + ctx := context.WithValue(context.Background(), TenantRoleContextKey, TenantRoleOwner) + ctx = WithTenantAPIKeyScope(ctx, TenantAPIKeyScope{ + Capabilities: StringArray{"retrieve"}, + KnowledgeBaseIDs: StringArray{"kb-1"}, + }) + got := CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-1", TenantID: 1}, 1) + if got != (KnowledgeBaseAccess{Read: true}) { + t.Fatalf("api key must cap owner role: %+v", got) + } +} + +func TestCallerKnowledgeBasePermissionJWTAdmin(t *testing.T) { + ctx := context.WithValue(context.Background(), TenantRoleContextKey, TenantRoleAdmin) + got := CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-1", TenantID: 1}, 1) + if got != (KnowledgeBaseAccess{Read: true, Write: true, Manage: true}) { + t.Fatalf("admin: %+v", got) + } +} + +func TestCallerKnowledgeBasePermissionJWTContributorOwnVsOther(t *testing.T) { + ctx := context.WithValue(context.Background(), TenantRoleContextKey, TenantRoleContributor) + ctx = context.WithValue(ctx, UserIDContextKey, "user-1") + own := CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-own", TenantID: 1, CreatorID: "user-1"}, 1) + if own != (KnowledgeBaseAccess{Read: true, Write: true, Manage: true}) { + t.Fatalf("own kb: %+v", own) + } + other := CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-other", TenantID: 1, CreatorID: "user-2"}, 1) + if other != (KnowledgeBaseAccess{Read: true}) { + t.Fatalf("other kb: %+v", other) + } +} + +func TestCallerKnowledgeBasePermissionCrossTenantIsReadOnly(t *testing.T) { + ctx := context.WithValue(context.Background(), TenantRoleContextKey, TenantRoleOwner) + got := CallerKnowledgeBasePermission(ctx, &KnowledgeBase{ID: "kb-shared", TenantID: 2}, 1) + if got != (KnowledgeBaseAccess{Read: true}) { + t.Fatalf("cross-tenant: %+v", got) + } +} diff --git a/internal/types/tenant_api_key.go b/internal/types/tenant_api_key.go index b3e68762122..70c63187cb7 100644 --- a/internal/types/tenant_api_key.go +++ b/internal/types/tenant_api_key.go @@ -24,6 +24,11 @@ type TenantAPIKey struct { 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:'[]'"` + // KnowledgeBasePermissions is an optional per-KB overlay on top of + // Capabilities. Keys are knowledge-base IDs; values are subsets of + // retrieve / ingest / manage_kbs. An empty map means every allow-listed + // KB inherits the key's global capabilities. + KnowledgeBasePermissions KnowledgeBasePermissionMap `json:"knowledge_base_permissions" 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 @@ -267,11 +272,12 @@ 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 + KeyID uint64 + ScopeType APIKeyScopeType + FullAccess bool + KnowledgeBaseIDs StringArray + KnowledgeBasePermissions KnowledgeBasePermissionMap + Capabilities StringArray } func WithTenantAPIKeyScope(ctx context.Context, scope TenantAPIKeyScope) context.Context { @@ -290,12 +296,16 @@ func TenantAPIKeyScopeFromContext(ctx context.Context) (TenantAPIKeyScope, bool) } func (s TenantAPIKeyScope) Normalize() TenantAPIKeyScope { + ids, perms := NormalizeKnowledgeBasePermissions( + s.KnowledgeBaseIDs, s.KnowledgeBasePermissions, s.Capabilities, s.FullAccess, + ) return TenantAPIKeyScope{ - KeyID: s.KeyID, - ScopeType: NormalizeAPIKeyScopeType(s.ScopeType), - FullAccess: s.FullAccess, - KnowledgeBaseIDs: normalizeIDArray(s.KnowledgeBaseIDs), - Capabilities: NormalizeAPIKeyCapabilities(s.Capabilities), + KeyID: s.KeyID, + ScopeType: NormalizeAPIKeyScopeType(s.ScopeType), + FullAccess: s.FullAccess, + KnowledgeBaseIDs: ids, + KnowledgeBasePermissions: perms, + Capabilities: NormalizeAPIKeyCapabilities(s.Capabilities), } } @@ -354,6 +364,34 @@ func (s TenantAPIKeyScope) AllowsKnowledgeBases(kbIDs []string) bool { return true } +// AllowsKnowledgeBaseCapability reports whether this scope may use cap against +// kbID. Global capabilities are always the ceiling; an empty permissions map +// inherits that ceiling for every allow-listed KB. +func (s TenantAPIKeyScope) AllowsKnowledgeBaseCapability(kbID string, cap APIKeyCapability) bool { + s = s.Normalize() + cap = storedKBPermissionCapability(cap) + if cap == "" || !isKBPermissionCapability(cap) { + return false + } + if s.FullAccess { + return true + } + if !knowledgeBasePermissionCeiling(s.Capabilities, cap) { + return false + } + if !s.IsKnowledgeBaseRestricted() { + return true + } + if !s.AllowsKnowledgeBase(kbID) { + return false + } + grants, ok := s.KnowledgeBasePermissions[strings.TrimSpace(kbID)] + if !ok { + return true + } + return grantsContainCapability(grants, cap) +} + func normalizeIDArray(in StringArray) StringArray { out := make(StringArray, 0, len(in)) seen := map[string]struct{}{} @@ -428,17 +466,50 @@ func FilterKnowledgeBasesForTenantAPIKeyScope( if !scope.AllowsKnowledgeBases(requestedKBIDs) { return nil, errors.NewForbiddenError("API key scope does not allow one or more knowledge bases") } + if knowledgeBasePermissionCeiling(scope.Capabilities, APIKeyCapabilityRetrieve) { + for _, id := range requestedKBIDs { + if !scope.AllowsKnowledgeBaseCapability(id, APIKeyCapabilityRetrieve) { + return nil, errors.NewForbiddenError("API key scope does not allow one or more knowledge bases") + } + } + } return resolvedKBIDs, nil } - allowed := make(map[string]struct{}, len(scope.KnowledgeBaseIDs)) - for _, id := range scope.KnowledgeBaseIDs { - allowed[id] = struct{}{} - } filtered := make([]string, 0, len(resolvedKBIDs)) for _, id := range resolvedKBIDs { - if _, ok := allowed[id]; ok { - filtered = append(filtered, id) + if !scope.AllowsKnowledgeBase(id) { + continue + } + if knowledgeBasePermissionCeiling(scope.Capabilities, APIKeyCapabilityRetrieve) && + !scope.AllowsKnowledgeBaseCapability(id, APIKeyCapabilityRetrieve) { + continue } + filtered = append(filtered, id) } return filtered, nil } + +// AuthorizeTenantAPIKeyKnowledgeBaseCapability rejects KB-restricted API key +// callers that lack cap on kbID. JWT callers and unrestricted keys pass +// through (route-level APIKeyGate still applies). +func AuthorizeTenantAPIKeyKnowledgeBaseCapability(ctx context.Context, kbID string, cap APIKeyCapability) error { + return AuthorizeTenantAPIKeyKnowledgeBaseAnyCapability(ctx, kbID, cap) +} + +// AuthorizeTenantAPIKeyKnowledgeBaseAnyCapability allows the request when any +// of the listed capabilities is granted on kbID. +func AuthorizeTenantAPIKeyKnowledgeBaseAnyCapability(ctx context.Context, kbID string, caps ...APIKeyCapability) error { + scope, ok := TenantAPIKeyScopeFromContext(ctx) + if !ok { + return nil + } + if len(caps) == 0 { + return nil + } + for _, cap := range caps { + if scope.AllowsKnowledgeBaseCapability(kbID, cap) { + return nil + } + } + return errors.NewForbiddenError("API key scope does not allow this operation on the knowledge base") +} diff --git a/internal/types/tenant_api_key_permissions.go b/internal/types/tenant_api_key_permissions.go new file mode 100644 index 00000000000..c72860e7fb6 --- /dev/null +++ b/internal/types/tenant_api_key_permissions.go @@ -0,0 +1,204 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "strings" +) + +// KnowledgeBasePermissionMap is kb_id -> enabled KB-scoped capabilities +// (retrieve, ingest, manage_kbs). An empty map means every allow-listed +// knowledge base inherits the key's global capabilities. +type KnowledgeBasePermissionMap map[string]StringArray + +func (m KnowledgeBasePermissionMap) Value() (driver.Value, error) { + if m == nil { + return []byte("{}"), nil + } + return json.Marshal(m) +} + +func (m *KnowledgeBasePermissionMap) Scan(src any) error { + if src == nil { + *m = KnowledgeBasePermissionMap{} + return nil + } + var raw []byte + switch v := src.(type) { + case []byte: + raw = v + case string: + raw = []byte(v) + default: + return errors.New("KnowledgeBasePermissionMap.Scan: unsupported source type") + } + if len(raw) == 0 { + *m = KnowledgeBasePermissionMap{} + return nil + } + var out KnowledgeBasePermissionMap + if err := json.Unmarshal(raw, &out); err != nil { + return err + } + if out == nil { + out = KnowledgeBasePermissionMap{} + } + *m = out + return nil +} + +func isKBPermissionCapability(c APIKeyCapability) bool { + switch c { + case APIKeyCapabilityRetrieve, APIKeyCapabilityIngest, APIKeyCapabilityManageKnowledgeBases: + return true + default: + return false + } +} + +func storedKBPermissionCapability(c APIKeyCapability) APIKeyCapability { + c = NormalizeAPIKeyCapability(c) + if c == APIKeyCapabilityChat { + return APIKeyCapabilityRetrieve + } + return c +} + +func knowledgeBasePermissionCeiling(capabilities StringArray, c APIKeyCapability) bool { + c = storedKBPermissionCapability(c) + norm := NormalizeAPIKeyCapabilities(capabilities) + has := func(want APIKeyCapability) bool { + for _, item := range norm { + if item == string(want) { + return true + } + } + return false + } + if c == APIKeyCapabilityRetrieve { + return has(APIKeyCapabilityRetrieve) || has(APIKeyCapabilityChat) + } + return has(c) +} + +func grantsContainCapability(grants StringArray, c APIKeyCapability) bool { + c = storedKBPermissionCapability(c) + if c == "" || !isKBPermissionCapability(c) { + return false + } + for _, item := range NormalizeAPIKeyCapabilities(grants) { + if item == string(c) { + return true + } + } + return false +} + +func normalizeKnowledgeBasePermissionMap( + ids StringArray, + perms KnowledgeBasePermissionMap, + capabilities StringArray, +) KnowledgeBasePermissionMap { + if len(perms) == 0 { + return KnowledgeBasePermissionMap{} + } + allowed := map[string]struct{}{} + for _, id := range normalizeIDArray(ids) { + allowed[id] = struct{}{} + } + out := KnowledgeBasePermissionMap{} + for kbID, grants := range perms { + kbID = strings.TrimSpace(kbID) + if kbID == "" { + continue + } + if _, ok := allowed[kbID]; !ok { + continue + } + filtered := StringArray{} + seen := map[string]struct{}{} + for _, item := range grants { + cap := storedKBPermissionCapability(APIKeyCapability(item)) + if !isKBPermissionCapability(cap) { + continue + } + if !knowledgeBasePermissionCeiling(capabilities, cap) { + continue + } + s := string(cap) + if _, dup := seen[s]; dup { + continue + } + seen[s] = struct{}{} + filtered = append(filtered, s) + } + if len(filtered) == 0 { + continue + } + out[kbID] = filtered + } + return out +} + +// NormalizeKnowledgeBasePermissions intersects per-KB grants with the allow-list +// and the key's global capability ceiling. Empty input stays empty (inherit). +// An explicit per-KB entry that normalizes to no capabilities is dropped from +// the allow-list (same as unchecking 可读 and clearing the row). +func NormalizeKnowledgeBasePermissions( + ids StringArray, + perms KnowledgeBasePermissionMap, + capabilities StringArray, + fullAccess bool, +) (StringArray, KnowledgeBasePermissionMap) { + if fullAccess { + return nil, KnowledgeBasePermissionMap{} + } + normalizedIDs := normalizeIDArray(ids) + if len(perms) == 0 { + return normalizedIDs, KnowledgeBasePermissionMap{} + } + trimmed := KnowledgeBasePermissionMap{} + for kbID, grants := range perms { + kbID = strings.TrimSpace(kbID) + if kbID == "" { + continue + } + trimmed[kbID] = grants + } + normalizedPerms := normalizeKnowledgeBasePermissionMap(normalizedIDs, trimmed, capabilities) + kept := StringArray{} + for _, id := range normalizedIDs { + if _, hadEntry := trimmed[id]; !hadEntry { + kept = append(kept, id) + continue + } + if _, ok := normalizedPerms[id]; ok { + kept = append(kept, id) + } + } + return kept, normalizeKnowledgeBasePermissionMap(kept, normalizedPerms, capabilities) +} + +// KnowledgeBasePermissionsExceedCeiling reports whether any stored grant is +// above the key's global capabilities (used to 400 on create/update). +func KnowledgeBasePermissionsExceedCeiling(perms KnowledgeBasePermissionMap, capabilities StringArray) bool { + if len(perms) == 0 { + return false + } + for _, grants := range perms { + for _, item := range grants { + cap := storedKBPermissionCapability(APIKeyCapability(item)) + if cap == "" { + continue + } + if !isKBPermissionCapability(cap) { + return true + } + if !knowledgeBasePermissionCeiling(capabilities, cap) { + return true + } + } + } + return false +} diff --git a/internal/types/tenant_api_key_scope_test.go b/internal/types/tenant_api_key_scope_test.go index c560d868df9..cc7dcba67d3 100644 --- a/internal/types/tenant_api_key_scope_test.go +++ b/internal/types/tenant_api_key_scope_test.go @@ -72,6 +72,151 @@ func TestScopeHasCapability(t *testing.T) { } } +func TestAllowsKnowledgeBaseCapabilityInheritsGlobalWhenMapEmpty(t *testing.T) { + s := TenantAPIKeyScope{ + KnowledgeBaseIDs: StringArray{"kb-1", "kb-2"}, + Capabilities: StringArray{"retrieve", "ingest", "manage_kbs"}, + } + if !s.AllowsKnowledgeBaseCapability("kb-1", APIKeyCapabilityIngest) { + t.Fatal("empty permissions map should inherit ingest for every allow-listed KB") + } + if !s.AllowsKnowledgeBaseCapability("kb-2", APIKeyCapabilityManageKnowledgeBases) { + t.Fatal("empty permissions map should inherit manage_kbs for every allow-listed KB") + } +} + +func TestAllowsKnowledgeBaseCapabilitySubtractsIngestOnOneKB(t *testing.T) { + s := TenantAPIKeyScope{ + KnowledgeBaseIDs: StringArray{"kb-1", "kb-2"}, + Capabilities: StringArray{"retrieve", "ingest"}, + KnowledgeBasePermissions: KnowledgeBasePermissionMap{ + "kb-1": StringArray{"retrieve"}, + }, + } + if s.AllowsKnowledgeBaseCapability("kb-1", APIKeyCapabilityIngest) { + t.Fatal("kb-1 should not allow ingest after the overlay removed it") + } + if !s.AllowsKnowledgeBaseCapability("kb-1", APIKeyCapabilityRetrieve) { + t.Fatal("kb-1 should still allow retrieve") + } + if !s.AllowsKnowledgeBaseCapability("kb-2", APIKeyCapabilityIngest) { + t.Fatal("kb-2 with no overlay entry should inherit ingest") + } +} + +func TestAllowsKnowledgeBaseCapabilityGlobalCeiling(t *testing.T) { + s := TenantAPIKeyScope{ + KnowledgeBaseIDs: StringArray{"kb-1"}, + Capabilities: StringArray{"retrieve"}, + KnowledgeBasePermissions: KnowledgeBasePermissionMap{ + "kb-1": StringArray{"retrieve", "ingest"}, + }, + } + if s.AllowsKnowledgeBaseCapability("kb-1", APIKeyCapabilityIngest) { + t.Fatal("ingest overlay must not exceed the global capability ceiling") + } +} + +func TestAllowsKnowledgeBaseCapabilityChatCountsAsReadCeiling(t *testing.T) { + s := TenantAPIKeyScope{ + KnowledgeBaseIDs: StringArray{"kb-1"}, + Capabilities: StringArray{"chat", "ingest"}, + } + if !s.AllowsKnowledgeBaseCapability("kb-1", APIKeyCapabilityRetrieve) { + t.Fatal("chat should satisfy the per-KB read ceiling") + } +} + +func TestAllowsKnowledgeBaseCapabilityManageWithoutIngest(t *testing.T) { + s := TenantAPIKeyScope{ + KnowledgeBaseIDs: StringArray{"kb-1"}, + Capabilities: StringArray{"retrieve", "ingest", "manage_kbs"}, + KnowledgeBasePermissions: KnowledgeBasePermissionMap{ + "kb-1": StringArray{"retrieve", "manage_kbs"}, + }, + } + if s.AllowsKnowledgeBaseCapability("kb-1", APIKeyCapabilityIngest) { + t.Fatal("manage-only overlay must not grant ingest") + } + if !s.AllowsKnowledgeBaseCapability("kb-1", APIKeyCapabilityManageKnowledgeBases) { + t.Fatal("manage overlay should grant manage_kbs") + } +} + +func TestAllowsKnowledgeBaseCapabilityFullAccessAndUnrestricted(t *testing.T) { + full := TenantAPIKeyScope{FullAccess: true} + if !full.AllowsKnowledgeBaseCapability("kb-any", APIKeyCapabilityIngest) { + t.Fatal("full-access keys skip the per-KB overlay") + } + unrestricted := TenantAPIKeyScope{Capabilities: StringArray{"ingest"}} + if !unrestricted.AllowsKnowledgeBaseCapability("kb-any", APIKeyCapabilityIngest) { + t.Fatal("unrestricted keys follow global capabilities only") + } +} + +func TestAuthorizeTenantAPIKeyKnowledgeBaseCapabilityRejectsWrite(t *testing.T) { + ctx := WithTenantAPIKeyScope(context.Background(), TenantAPIKeyScope{ + KnowledgeBaseIDs: StringArray{"kb-1"}, + Capabilities: StringArray{"retrieve", "ingest"}, + KnowledgeBasePermissions: KnowledgeBasePermissionMap{ + "kb-1": StringArray{"retrieve"}, + }, + }) + if err := AuthorizeTenantAPIKeyKnowledgeBaseCapability(ctx, "kb-1", APIKeyCapabilityIngest); err == nil { + t.Fatal("expected forbidden ingest on a read-only KB overlay") + } + if err := AuthorizeTenantAPIKeyKnowledgeBaseCapability(ctx, "kb-1", APIKeyCapabilityRetrieve); err != nil { + t.Fatalf("retrieve should pass, got %v", err) + } +} + +func TestFilterKnowledgeBasesForTenantAPIKeyScopeDropsUnreadKBs(t *testing.T) { + ctx := WithTenantAPIKeyScope(context.Background(), TenantAPIKeyScope{ + KnowledgeBaseIDs: StringArray{"kb-1", "kb-2"}, + Capabilities: StringArray{"retrieve", "ingest"}, + KnowledgeBasePermissions: KnowledgeBasePermissionMap{ + "kb-2": StringArray{"ingest"}, + }, + }) + got, err := FilterKnowledgeBasesForTenantAPIKeyScope(ctx, nil, []string{"kb-1", "kb-2"}) + if err != nil { + t.Fatalf("FilterKnowledgeBasesForTenantAPIKeyScope returned error: %v", err) + } + if len(got) != 1 || got[0] != "kb-1" { + t.Fatalf("filtered = %#v, want only kb-1 (kb-2 has no retrieve)", got) + } +} + +func TestKnowledgeBasePermissionsExceedCeiling(t *testing.T) { + if KnowledgeBasePermissionsExceedCeiling( + KnowledgeBasePermissionMap{"kb-1": StringArray{"ingest"}}, + StringArray{"retrieve"}, + ) != true { + t.Fatal("ingest grant without global ingest should exceed the ceiling") + } + if KnowledgeBasePermissionsExceedCeiling( + KnowledgeBasePermissionMap{"kb-1": StringArray{"retrieve"}}, + StringArray{"retrieve", "chat"}, + ) { + t.Fatal("retrieve under a retrieve/chat key should not exceed the ceiling") + } +} + +func TestNormalizeKnowledgeBasePermissionsDropsEmptyGrantRows(t *testing.T) { + ids, perms := NormalizeKnowledgeBasePermissions( + StringArray{"kb-1", "kb-2"}, + KnowledgeBasePermissionMap{"kb-1": StringArray{}}, + StringArray{"retrieve", "ingest"}, + false, + ) + if len(ids) != 1 || ids[0] != "kb-2" { + t.Fatalf("ids = %#v, want [kb-2]", ids) + } + if len(perms) != 0 { + t.Fatalf("perms = %#v, want empty inherit map", perms) + } +} + func TestNormalizeAPIKeyCapabilities(t *testing.T) { got := NormalizeAPIKeyCapabilities(StringArray{ " Retrieve ", diff --git a/migrations/sqlite/000017_api_key_kb_permissions.down.sql b/migrations/sqlite/000017_api_key_kb_permissions.down.sql new file mode 100644 index 00000000000..a5800ac7034 --- /dev/null +++ b/migrations/sqlite/000017_api_key_kb_permissions.down.sql @@ -0,0 +1 @@ +ALTER TABLE tenant_api_keys DROP COLUMN knowledge_base_permissions; diff --git a/migrations/sqlite/000017_api_key_kb_permissions.up.sql b/migrations/sqlite/000017_api_key_kb_permissions.up.sql new file mode 100644 index 00000000000..354aa360ccb --- /dev/null +++ b/migrations/sqlite/000017_api_key_kb_permissions.up.sql @@ -0,0 +1,4 @@ +-- Mirrors versioned migration 000096_api_key_kb_permissions: +-- per-KB retrieve/ingest/manage_kbs overlay on scoped tenant API keys. + +ALTER TABLE tenant_api_keys ADD COLUMN knowledge_base_permissions TEXT NOT NULL DEFAULT '{}'; diff --git a/migrations/versioned/000096_api_key_kb_permissions.down.sql b/migrations/versioned/000096_api_key_kb_permissions.down.sql new file mode 100644 index 00000000000..49fe4afbf3f --- /dev/null +++ b/migrations/versioned/000096_api_key_kb_permissions.down.sql @@ -0,0 +1,3 @@ +DO $$ BEGIN RAISE NOTICE '[Migration 000096 down] Dropping tenant_api_keys.knowledge_base_permissions'; END $$; + +ALTER TABLE tenant_api_keys DROP COLUMN IF EXISTS knowledge_base_permissions; diff --git a/migrations/versioned/000096_api_key_kb_permissions.up.sql b/migrations/versioned/000096_api_key_kb_permissions.up.sql new file mode 100644 index 00000000000..b5b309c22d1 --- /dev/null +++ b/migrations/versioned/000096_api_key_kb_permissions.up.sql @@ -0,0 +1,10 @@ +-- Description: Per-knowledge-base capability overlay for scoped tenant API keys. +-- Empty object means every allow-listed KB inherits the key's global +-- retrieve / ingest / manage_kbs capabilities. +DO $$ BEGIN RAISE NOTICE '[Migration 000096] Adding tenant_api_keys.knowledge_base_permissions'; END $$; + +ALTER TABLE tenant_api_keys + ADD COLUMN IF NOT EXISTS knowledge_base_permissions JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN tenant_api_keys.knowledge_base_permissions IS + 'Per-KB capability subset (retrieve/ingest/manage_kbs). Empty object inherits the key global capabilities for every allow-listed knowledge base.'; diff --git a/packages/dsh-weknora/src/client.ts b/packages/dsh-weknora/src/client.ts index 1975a972354..8b894118f80 100644 --- a/packages/dsh-weknora/src/client.ts +++ b/packages/dsh-weknora/src/client.ts @@ -20,6 +20,21 @@ export interface KnowledgeBaseSummary { id?: string name?: string description?: string + type?: string + knowledge_count?: number + chunk_count?: number + capabilities?: { + vector?: boolean + keyword?: boolean + wiki?: boolean + graph?: boolean + faq?: boolean + } + permission?: { + read?: boolean + write?: boolean + manage?: boolean + } [key: string]: unknown } diff --git a/website-docs/03-features/02-knowledge-base.md b/website-docs/03-features/02-knowledge-base.md index 09d4875f8c5..f8c6aa92a76 100644 --- a/website-docs/03-features/02-knowledge-base.md +++ b/website-docs/03-features/02-knowledge-base.md @@ -189,7 +189,7 @@ graph TB #### KB 计算字段 {#_1-7-kb-计算字段} -列表 / 详情响应附带:`knowledge_count`、`chunk_count`、`is_processing`(FAQ 库)、`processing_count`(文档库处理中知识数)、`share_count`(共享到的组织数)、`creator_name`、`is_pinned` / `pinned_at`(当前用户置顶状态)。 +列表 / 详情响应附带:`knowledge_count`、`chunk_count`、`is_processing`(FAQ 库)、`processing_count`(文档库处理中知识数)、`share_count`(共享到的组织数)、`creator_name`、`is_pinned` / `pinned_at`(当前用户置顶状态)、`capabilities`(向量/关键词/wiki/图谱/FAQ)、`permission`(当前调用方对该库的 `read` / `write` / `manage`)。 另有一个存储字段 `is_temporary`:标记**临时(ephemeral)知识库**,正常的知识库列表里不展示。它由系统内部使用,典型场景是联网搜索把抓回来的网页缓存成可检索内容。手工建库不会产生临时库。 diff --git a/website-docs/04-api/02-api-knowledge.md b/website-docs/04-api/02-api-knowledge.md index e344b165dd2..a4a2b018ed1 100644 --- a/website-docs/04-api/02-api-knowledge.md +++ b/website-docs/04-api/02-api-knowledge.md @@ -54,7 +54,7 @@ curl -X PUT "$BASE/api/v1/knowledge-bases/kb-1" \ ### GET /api/v1/knowledge-bases -用途:知识库列表。权限:Viewer+;API key `retrieve`/full。 +用途:知识库列表。权限:Viewer+;API key `retrieve`/full。每条记录带 `permission: {read, write, manage}`(可读 / 可写 / 可管理)。 | 查询参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | @@ -70,7 +70,7 @@ curl $BASE/api/v1/knowledge-bases -H "X-API-Key: $API_KEY" ### GET /api/v1/knowledge-bases/:id -用途:知识库详情(共享 KB 携带 `my_permission`)。权限:Viewer+,KB read。查询参数:`agent_id`(可选)。 +用途:知识库详情(共享 KB 携带 `my_permission`)。权限:Viewer+,KB read。查询参数:`agent_id`(可选)。响应同样带 `permission: {read, write, manage}`。 响应:200 `{"success":true,"data":{KnowledgeBase}}`