diff --git a/client/tenant.go b/client/tenant.go index 90e50353..c03bfac9 100644 --- a/client/tenant.go +++ b/client/tenant.go @@ -32,8 +32,6 @@ type Tenant struct { Name string `yaml:"name" json:"name"` // Tenant description Description string `yaml:"description" json:"description"` - // API key for authentication - APIKey string `yaml:"api_key" json:"api_key"` // Tenant status (active, inactive) Status string `yaml:"status" json:"status" gorm:"default:'active'"` // Configured retrieval engines @@ -64,6 +62,54 @@ type TenantListResponse struct { } `json:"data"` } +// TenantAPIKeyScope is an operation scope for revocable tenant API keys. +type TenantAPIKeyScope string + +const ( + TenantAPIKeyScopeRead TenantAPIKeyScope = "read" + TenantAPIKeyScopeWrite TenantAPIKeyScope = "write" + TenantAPIKeyScopeAdmin TenantAPIKeyScope = "admin" +) + +// TenantAPIKey is the API key metadata returned by list/create APIs. +type TenantAPIKey struct { + ID uint64 `json:"id"` + TenantID uint64 `json:"tenant_id"` + Name string `json:"name"` + APIKey string `json:"api_key"` + Scopes []TenantAPIKeyScope `json:"scopes"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + 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 scoped, revocable tenant API key. +type CreateTenantAPIKeyRequest struct { + Name string `json:"name"` + Scopes []TenantAPIKeyScope `json:"scopes,omitempty"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"` + ExpiresAtUnix *int64 `json:"expires_at_unix,omitempty"` +} + +// CreatedTenantAPIKey includes the created API key. Token is kept for +// backward-compatible clients; APIKey is also returned by list APIs. +type CreatedTenantAPIKey struct { + TenantAPIKey + Token string `json:"token,omitempty"` +} + +type tenantAPIKeyListResponse struct { + Success bool `json:"success"` + Data []TenantAPIKey `json:"data"` +} + +type tenantAPIKeyCreateResponse struct { + Success bool `json:"success"` + Data CreatedTenantAPIKey `json:"data"` +} + // CreateTenant creates a new tenant func (c *Client) CreateTenant(ctx context.Context, tenant *Tenant) (*Tenant, error) { resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/tenants", tenant, nil) @@ -193,6 +239,53 @@ func (c *Client) SearchTenants(ctx context.Context, keyword string, tenantID uin return response.Data.Items, response.Data.Total, nil } +// ListTenantAPIKeys lists API keys for a tenant. +func (c *Client) ListTenantAPIKeys(ctx context.Context, tenantID uint64) ([]TenantAPIKey, error) { + path := fmt.Sprintf("/api/v1/tenants/%d/api-keys", tenantID) + resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil) + if err != nil { + return nil, err + } + + var response tenantAPIKeyListResponse + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + return response.Data, nil +} + +// CreateTenantAPIKey creates a scoped API key. +func (c *Client) CreateTenantAPIKey( + ctx context.Context, tenantID uint64, req *CreateTenantAPIKeyRequest, +) (*CreatedTenantAPIKey, error) { + path := fmt.Sprintf("/api/v1/tenants/%d/api-keys", tenantID) + resp, err := c.doRequest(ctx, http.MethodPost, path, req, nil) + if err != nil { + return nil, err + } + + var response tenantAPIKeyCreateResponse + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + return &response.Data, nil +} + +// DeleteTenantAPIKey revokes a tenant API key. +func (c *Client) DeleteTenantAPIKey(ctx context.Context, tenantID uint64, keyID uint64) error { + path := fmt.Sprintf("/api/v1/tenants/%d/api-keys/%d", tenantID, keyID) + resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil) + if err != nil { + return err + } + + var response struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + } + return parseResponse(resp, &response) +} + // GetTenantKV retrieves a tenant KV configuration by key func (c *Client) GetTenantKV(ctx context.Context, key string) (json.RawMessage, error) { path := fmt.Sprintf("/api/v1/tenants/kv/%s", key) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 70933881..2f66dc96 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -58,7 +58,6 @@ const syncOIDCUserContext = async () => { authStore.setTenant({ id: String(tenant.id) || '', name: tenant.name || '', - api_key: tenant.api_key || '', owner_id: tenant.owner_id || user.id || '', description: tenant.description, status: tenant.status, diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index c823ca28..5c8f556c 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -28,7 +28,6 @@ export interface LoginResponse { id: number name: string description: string - api_key: string status: string business: string storage_quota: number @@ -43,7 +42,6 @@ export interface LoginResponse { id: number name: string description?: string - api_key?: string status?: string business?: string storage_quota?: number @@ -88,7 +86,6 @@ export interface RegisterResponse { tenant: { id: string name: string - api_key: string } } } @@ -163,7 +160,6 @@ export interface TenantInfo { id: string name: string description?: string - api_key: string status?: string business?: string owner_id: string diff --git a/frontend/src/api/tenant/index.ts b/frontend/src/api/tenant/index.ts index 162a91e1..09bd41de 100644 --- a/frontend/src/api/tenant/index.ts +++ b/frontend/src/api/tenant/index.ts @@ -8,7 +8,6 @@ export interface TenantInfo { id: number name: string description?: string - api_key?: string status?: string business?: string storage_quota?: number @@ -49,6 +48,30 @@ export interface APIPrincipalTestToken { external_user_id: string } +export type TenantAPIKeyScope = 'read' | 'write' | 'admin' + +export interface TenantAPIKey { + id: number + name: string + api_key: string + scopes: TenantAPIKeyScope[] + knowledge_base_ids: string[] + last_used_at?: string + expires_at?: string + created_at: string +} + +export interface CreatedTenantAPIKey extends TenantAPIKey { + token?: string +} + +export interface CreateTenantAPIKeyPayload { + name: string + scopes: TenantAPIKeyScope[] + knowledge_base_ids?: string[] + expires_at_unix?: number +} + // 搜索租户参数 export interface SearchTenantsParams { keyword?: string @@ -146,6 +169,50 @@ export async function createAPIPrincipalTestToken( } } +export async function listTenantAPIKeys( + tenantId: number, +): Promise<{ success: boolean; data?: TenantAPIKey[]; message?: string }> { + try { + const response = await get(`/api/v1/tenants/${tenantId}/api-keys`) + return response as unknown as { success: boolean; data?: TenantAPIKey[]; message?: string } + } catch (error: any) { + return { + success: false, + message: error.message || t('error.tenant.listApiKeysFailed'), + } + } +} + +export async function createTenantAPIKey( + tenantId: number, + payload: CreateTenantAPIKeyPayload, +): Promise<{ success: boolean; data?: CreatedTenantAPIKey; message?: string }> { + try { + const response = await post(`/api/v1/tenants/${tenantId}/api-keys`, payload) + return response as unknown as { success: boolean; data?: CreatedTenantAPIKey; message?: string } + } catch (error: any) { + return { + success: false, + message: error.message || t('error.tenant.createApiKeyFailed'), + } + } +} + +export async function deleteTenantAPIKey( + tenantId: number, + keyId: number, +): Promise<{ success: boolean; message?: string }> { + try { + const response = await del(`/api/v1/tenants/${tenantId}/api-keys/${keyId}`) + return response as unknown as { success: boolean; message?: string } + } catch (error: any) { + return { + success: false, + message: error.message || t('error.tenant.deleteApiKeyFailed'), + } + } +} + /** * 更新租户信息(目前暴露名称、描述两个字段的编辑入口)。 * 后端 `PUT /tenants/:id` 用指针字段区分"未传"和"显式空串",未传的列不会 @@ -186,8 +253,8 @@ export async function deleteTenant( /** * 创建新工作区(任意已登录用户均可调用)。 - * 后端会自动把调用者写成新租户的 Owner,并生成 api_key、默认 storage_quota - * 等服务端字段,所以这里只暴露 name + description。 + * 后端会自动把调用者写成新租户的 Owner,并填充默认 storage_quota + * 等服务端字段;API Key 由用户在集成页手动创建。 * 路由:POST /api/v1/tenants(router 上不挂 g.CrossTenant(),自助场景使用)。 */ export async function createTenant( diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index 9717a493..144a87a9 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -99,9 +99,9 @@ {{ $t('settings.mcpService') }} - -
-
- -

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

+ + +
+
+ +

+ {{ $t('tenant.api.docDescription') }} + + {{ $t('tenant.api.openDoc') }} + + +

+
+
+ +
+
+
+ +

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

+
+ + + {{ $t('integrations.api.createApiKey') }}
+
+
+
+ + {{ $t('integrations.api.loading') }} +
+
+ {{ $t('integrations.api.noApiKeys') }} +
+
+ + + + + + + + + + + + + + + + + + + + + +
{{ $t('integrations.api.apiKeyName') }}{{ $t('integrations.api.apiKeyValue') }}{{ $t('integrations.api.apiKeyScopes') }}{{ $t('integrations.api.apiKeyKnowledgeScope') }}{{ $t('integrations.api.createdAt') }}{{ $t('integrations.api.actions') }}
+ {{ key.name }} + + {{ formatKeyMaskedValue(key) }} + +
+ + {{ scope }} + +
+
+ + {{ formatKeyKnowledgeScope(key.knowledge_base_ids) }} + + + {{ formatDate(key.created_at) }} + +
+ + + + + + + + + +
+
+
+
+
@@ -298,6 +436,93 @@

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

+ + + + +
+
+
+ +
+ +
+ +
+
+ +
+ + {{ $t('integrations.api.scopeRead') }} + {{ $t('integrations.api.scopeWrite') }} + {{ $t('integrations.api.scopeAdmin') }} + +
+

+ {{ $t('integrations.api.scopeRead') }} + {{ $t('integrations.api.scopeReadDesc') }} +

+

+ {{ $t('integrations.api.scopeWrite') }} + {{ $t('integrations.api.scopeWriteDesc') }} +

+

+ {{ $t('integrations.api.scopeAdmin') }} + {{ $t('integrations.api.scopeAdminDesc') }} +

+
+
+ +
+
+ +
+ +
+
+ + +
+
@@ -309,12 +534,18 @@ import { getCurrentUser } from '@/api/auth' import { listAgents, BUILTIN_SMART_REASONING_ID, type CustomAgent } from '@/api/agent' import SettingDrawer from '@/components/settings/SettingDrawer.vue' import { + createTenantAPIKey, + deleteTenantAPIKey, createAPIPrincipalTestToken, getAPIPrincipalConfig, + listTenantAPIKeys, updateAPIPrincipalConfig, type APIPrincipalConfig, type APIPrincipalMode, + type TenantAPIKey, + type TenantAPIKeyScope, } from '@/api/tenant' +import { listKnowledgeBases } from '@/api/knowledge-base' import { getApiBaseUrl } from '@/utils/api-base' const { t } = useI18n() @@ -327,8 +558,14 @@ const saving = ref(false) const error = ref('') const tenantId = ref(0) const apiKey = ref('') -const showApiKey = ref(false) const config = ref(null) +const apiKeys = ref([]) +const apiKeysLoading = ref(false) +const apiKeyDialogVisible = ref(false) +const apiKeyCreating = ref(false) +const visibleAPIKeyIDs = ref>(new Set()) +const knowledgeBasesLoading = ref(false) +const knowledgeBases = ref>([]) const secretInput = ref('') const savedHMACSecret = ref('') const exampleTab = ref<'jwt' | 'curl'>('curl') @@ -338,6 +575,13 @@ const agentsError = ref('') const playgroundDrawerVisible = ref(false) const playgroundController = ref(null) const showHMACSecret = ref(false) +const wailsApiBaseURL = ref(null) +const wailsApiLanBaseURL = ref(null) +const showDesktopPortSetting = ref(false) +const showDesktopBindPublicSetting = ref(false) +const desktopPortInput = ref(0) +const desktopBindPublicInput = ref(false) +const desktopListenPublicActive = ref(false) const form = reactive({ mode: 'tenant' as APIPrincipalMode, @@ -346,8 +590,32 @@ const form = reactive({ require_direct_header: false, }) +const apiKeyForm = reactive({ + name: '', + scopes: ['read'] as TenantAPIKeyScope[], + knowledge_base_ids: [] as string[], +}) + type PlaygroundStatus = '' | 'running' | 'success' | 'failed' | 'stopped' +type WeKnoraDesktopWindow = Window & { + __WEKNORA_API_BASE__?: string + __WEKNORA_API_LAN_BASE__?: string + go?: { + main?: { + App?: { + GetAPIBaseURL?: () => Promise | string + GetAPILanBaseURL?: () => Promise | string + GetDesktopHTTPPortSetting?: () => Promise | number + GetDesktopHTTPBindPublicSetting?: () => Promise | boolean + GetDesktopListenPublicActive?: () => Promise | boolean + SetDesktopHTTPPortSetting?: (port: number) => Promise | void + SetDesktopHTTPBindPublicSetting?: (v: boolean) => Promise | void + } + } + } +} + const playground = reactive({ agent_id: '', query: 'hello', @@ -375,16 +643,19 @@ watch(playgroundDrawerVisible, (visible) => { }) const apiBaseUrl = computed(() => { + if (wailsApiBaseURL.value) { + return wailsApiBaseURL.value + } const configured = getApiBaseUrl().trim().replace(/\/$/, '') const origin = typeof window !== 'undefined' && window.location.origin !== 'null' ? window.location.origin : '' return `${configured || origin}/api/v1` }) -const displayApiKey = computed(() => { - if (!apiKey.value) return '' - if (showApiKey.value) return apiKey.value - return '•'.repeat(apiKey.value.length) -}) +const showLanUrlUnavailableHint = computed(() => ( + showDesktopBindPublicSetting.value + && desktopListenPublicActive.value + && !wailsApiLanBaseURL.value +)) const tokenHeaderName = computed(() => DEFAULT_TOKEN_HEADER_NAME) @@ -403,6 +674,11 @@ const agentOptions = computed(() => agents.value.map((agent) => ({ value: agent.id, }))) +const knowledgeBaseOptions = computed(() => knowledgeBases.value.map((kb) => ({ + label: kb.name || kb.id, + value: kb.id, +}))) + const hasUnsavedPrincipalChanges = computed(() => { const cfg = config.value if (!cfg) return false @@ -540,7 +816,10 @@ async function load() { throw new Error(t('integrations.api.loadFailed')) } tenantId.value = Number(tenant.id) - apiKey.value = tenant.api_key || '' + await Promise.all([ + loadAPIKeys(), + loadKnowledgeBaseOptions(), + ]) const cfgResp = await getAPIPrincipalConfig(tenantId.value) if (!cfgResp.success || !cfgResp.data) { @@ -561,6 +840,41 @@ async function load() { } } +async function loadAPIKeys() { + if (!tenantId.value) return + apiKeysLoading.value = true + try { + const resp = await listTenantAPIKeys(tenantId.value) + if (!resp.success) { + throw new Error(resp.message || t('integrations.api.loadApiKeysFailed')) + } + apiKeys.value = resp.data || [] + if (!apiKey.value && apiKeys.value.length > 0) { + apiKey.value = apiKeys.value[0].api_key || '' + } + } catch (err: any) { + MessagePlugin.error(err?.message || t('integrations.api.loadApiKeysFailed')) + } finally { + apiKeysLoading.value = false + } +} + +async function loadKnowledgeBaseOptions() { + knowledgeBasesLoading.value = true + try { + const resp: any = await listKnowledgeBases({ creator: 'all' }) + const rows = Array.isArray(resp?.data) ? resp.data : [] + knowledgeBases.value = rows.map((item: any) => ({ + id: String(item.id), + name: item.name || item.id, + })) + } catch { + knowledgeBases.value = [] + } finally { + knowledgeBasesLoading.value = false + } +} + async function loadAgents() { agentsLoading.value = true agentsError.value = '' @@ -665,10 +979,245 @@ async function saveIfNeeded(options: { showSuccess?: boolean } = {}) { async function copy(text: string) { if (!text) return - await navigator.clipboard.writeText(text) + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + } else { + const textArea = document.createElement('textarea') + textArea.value = text + textArea.style.position = 'fixed' + textArea.style.opacity = '0' + document.body.appendChild(textArea) + textArea.select() + document.execCommand('copy') + document.body.removeChild(textArea) + } MessagePlugin.success(t('integrations.api.copySuccess')) } +async function tryLoadWailsApiBaseURL() { + const win = window as WeKnoraDesktopWindow + for (let i = 0; i < 40; i++) { + const injected = win.__WEKNORA_API_BASE__ + if (typeof injected === 'string' && injected.trim()) { + wailsApiBaseURL.value = injected.trim().replace(/\/$/, '') + await tryLoadWailsLanHints(win) + return + } + const fn = win.go?.main?.App?.GetAPIBaseURL + if (typeof fn === 'function') { + try { + const raw = await Promise.resolve(fn()) + if (typeof raw === 'string' && raw.trim()) { + wailsApiBaseURL.value = raw.trim().replace(/\/$/, '') + } + } catch { + /* binding error */ + } + await tryLoadWailsLanHints(win) + return + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + await tryLoadWailsLanHints(win) +} + +async function tryLoadWailsLanHints(win: WeKnoraDesktopWindow) { + const injectedLan = win.__WEKNORA_API_LAN_BASE__ + if (typeof injectedLan === 'string' && injectedLan.trim()) { + wailsApiLanBaseURL.value = injectedLan.trim().replace(/\/$/, '') + } + const fnLan = win.go?.main?.App?.GetAPILanBaseURL + if (typeof fnLan === 'function' && !wailsApiLanBaseURL.value) { + try { + const raw = await Promise.resolve(fnLan()) + if (typeof raw === 'string' && raw.trim()) { + wailsApiLanBaseURL.value = raw.trim().replace(/\/$/, '') + } + } catch { + /* binding error */ + } + } + const fnAct = win.go?.main?.App?.GetDesktopListenPublicActive + if (typeof fnAct === 'function') { + try { + desktopListenPublicActive.value = !!(await Promise.resolve(fnAct())) + } catch { + desktopListenPublicActive.value = false + } + } +} + +function desktopPortBindingsAvailable(win: WeKnoraDesktopWindow) { + const app = win.go?.main?.App + return typeof app?.GetDesktopHTTPPortSetting === 'function' && typeof app?.SetDesktopHTTPPortSetting === 'function' +} + +function desktopBindPublicBindingsAvailable(win: WeKnoraDesktopWindow) { + const app = win.go?.main?.App + return ( + typeof app?.GetDesktopHTTPBindPublicSetting === 'function' && + typeof app?.SetDesktopHTTPBindPublicSetting === 'function' + ) +} + +async function loadDesktopApiPrefs() { + const win = window as WeKnoraDesktopWindow + if (desktopPortBindingsAvailable(win)) { + showDesktopPortSetting.value = true + try { + const port = await Promise.resolve(win.go!.main!.App!.GetDesktopHTTPPortSetting!()) + desktopPortInput.value = typeof port === 'number' ? port : 0 + } catch { + desktopPortInput.value = 0 + } + } + if (desktopBindPublicBindingsAvailable(win)) { + showDesktopBindPublicSetting.value = true + try { + const bind = await Promise.resolve(win.go!.main!.App!.GetDesktopHTTPBindPublicSetting!()) + desktopBindPublicInput.value = !!bind + } catch { + desktopBindPublicInput.value = false + } + } +} + +const onDesktopBindPublicChange = async (value: boolean) => { + const next = value === true + const fn = (window as WeKnoraDesktopWindow).go?.main?.App?.SetDesktopHTTPBindPublicSetting + if (typeof fn !== 'function') return + try { + await Promise.resolve(fn(next)) + MessagePlugin.success(t('tenant.api.desktopBindPublicSaved')) + } catch (err: unknown) { + MessagePlugin.error(err instanceof Error ? err.message : t('tenant.api.desktopBindPublicSaveFailed')) + desktopBindPublicInput.value = !next + } +} + +const saveDesktopPort = async () => { + const value = desktopPortInput.value + const port = typeof value === 'number' && !Number.isNaN(value) ? Math.floor(value) : 0 + if (port < 0 || port > 65535) { + MessagePlugin.warning(t('tenant.api.desktopPortInvalid')) + return + } + const fn = (window as WeKnoraDesktopWindow).go?.main?.App?.SetDesktopHTTPPortSetting + if (typeof fn !== 'function') return + try { + await Promise.resolve(fn(port)) + MessagePlugin.success(t('tenant.api.desktopPortSaved')) + } catch (err: unknown) { + MessagePlugin.error(err instanceof Error ? err.message : t('tenant.api.desktopPortSaveFailed')) + } +} + +function openApiDoc() { + window.open('https://github.com/Tencent/WeKnora/blob/main/docs/api/README.md', '_blank') +} + +function openCreateAPIKeyDialog() { + apiKeyForm.name = '' + apiKeyForm.scopes = ['read'] + apiKeyForm.knowledge_base_ids = [] + apiKeyDialogVisible.value = true + void loadKnowledgeBaseOptions() +} + +async function createScopedAPIKey() { + if (!apiKeyForm.name.trim()) { + MessagePlugin.error(t('integrations.api.apiKeyNameRequired')) + return + } + if (apiKeyForm.scopes.length === 0) { + MessagePlugin.error(t('integrations.api.apiKeyScopeRequired')) + return + } + apiKeyCreating.value = true + try { + const resp = await createTenantAPIKey(tenantId.value, { + name: apiKeyForm.name.trim(), + scopes: apiKeyForm.scopes, + knowledge_base_ids: apiKeyForm.knowledge_base_ids, + }) + if (!resp.success || !resp.data?.api_key) { + throw new Error(resp.message || t('integrations.api.createApiKeyFailed')) + } + apiKeyDialogVisible.value = false + apiKey.value = resp.data.api_key + visibleAPIKeyIDs.value = new Set([...visibleAPIKeyIDs.value, resp.data.id]) + MessagePlugin.success(t('integrations.api.apiKeyCreated')) + await loadAPIKeys() + } catch (err: any) { + MessagePlugin.error(err?.message || t('integrations.api.createApiKeyFailed')) + } finally { + apiKeyCreating.value = false + } +} + +function confirmDeleteAPIKey(id: number) { + const dialog = DialogPlugin.confirm({ + header: t('integrations.api.deleteApiKey'), + body: t('integrations.api.deleteApiKeyConfirm'), + confirmBtn: { content: t('integrations.api.deleteApiKey'), theme: 'danger' }, + cancelBtn: t('common.cancel'), + onConfirm: async () => { + await deleteScopedAPIKey(id) + dialog.destroy() + }, + onClose: () => dialog.destroy(), + }) +} + +async function deleteScopedAPIKey(id: number) { + const resp = await deleteTenantAPIKey(tenantId.value, id) + if (!resp.success) { + MessagePlugin.error(resp.message || t('integrations.api.deleteApiKeyFailed')) + return + } + MessagePlugin.success(t('integrations.api.deleteApiKeySuccess')) + await loadAPIKeys() +} + +function formatKeyKnowledgeScope(ids: string[] = []) { + if (!ids.length) return t('integrations.api.allKnowledgeBases') + const names = ids.map((id) => knowledgeBases.value.find((kb) => kb.id === id)?.name || id) + return names.join(', ') +} + +function isAPIKeyVisible(id: number) { + return visibleAPIKeyIDs.value.has(id) +} + +function toggleAPIKeyVisible(id: number) { + const next = new Set(visibleAPIKeyIDs.value) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + visibleAPIKeyIDs.value = next +} + +function formatKeyMaskedValue(key: TenantAPIKey) { + const value = key.api_key || '' + if (!value) return '-' + if (isAPIKeyVisible(key.id)) return value + return maskAPIKey(value) +} + +function maskAPIKey(value: string) { + if (value.length <= 12) return '*'.repeat(value.length) + return `${value.slice(0, 8)}${'*'.repeat(8)}${value.slice(-6)}` +} + +function formatDate(value?: string) { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '-' + return date.toLocaleString() +} + function buildPlaygroundHeaders(maskSecrets: boolean) { const commonHeaders: Record = { Accept: 'application/json', @@ -846,10 +1395,52 @@ function stopPlayground() { playgroundController.value?.abort() } -onMounted(load) +onMounted(async () => { + await tryLoadWailsApiBaseURL() + await loadDesktopApiPrefs() + await load() +}) onBeforeUnmount(stopPlayground) + + diff --git a/frontend/src/views/integrations/IntegrationsModal.vue b/frontend/src/views/integrations/IntegrationsModal.vue deleted file mode 100644 index 27502a41..00000000 --- a/frontend/src/views/integrations/IntegrationsModal.vue +++ /dev/null @@ -1,394 +0,0 @@ - - - - - diff --git a/frontend/src/views/platform/index.vue b/frontend/src/views/platform/index.vue index 3e95d8fa..630b1a4a 100644 --- a/frontend/src/views/platform/index.vue +++ b/frontend/src/views/platform/index.vue @@ -10,7 +10,6 @@
- -
- - {{ $t('tenant.loadingInfo') }} -
- - -
- - - -
- - -
- -
-
- -

{{ $t('tenant.api.keyDescription') }}

-
-
-
- - - - - - - - - - -
-
-
- - -
-
- -

{{ $t('tenant.api.urlDescription') }}

-
-
-
- - - - -
-
-
- - - - - -
-
- -

- {{ $t('tenant.api.docDescription') }} - - {{ $t('tenant.api.openDoc') }} - - -

-
-
- -
- - - - - - diff --git a/frontend/src/views/settings/Settings.vue b/frontend/src/views/settings/Settings.vue index 54d32b42..b3aee774 100644 --- a/frontend/src/views/settings/Settings.vue +++ b/frontend/src/views/settings/Settings.vue @@ -44,6 +44,7 @@ + {{ item.emoji }} {{ item.label }}
@@ -155,9 +155,9 @@
- -
- + +
+
@@ -182,7 +182,6 @@ import { useAuthStore } from '@/stores/auth' import { useI18n } from 'vue-i18n' import SystemInfo from './SystemInfo.vue' import TenantInfo from './TenantInfo.vue' -import ApiInfo from './ApiInfo.vue' import UserProfile from './UserProfile.vue' import GeneralSettings from './GeneralSettings.vue' import ModelSettings from './ModelSettings.vue' @@ -196,6 +195,13 @@ import StorageEngineSettings from './StorageEngineSettings.vue' import WeKnoraCloudSettings from './WeKnoraCloudSettings.vue' import TenantMembers from './TenantMembers.vue' import SystemSettings from '@/views/system/SystemSettings.vue' +import IntegrationSettingsSection from '@/views/integrations/IntegrationSettingsSection.vue' +import { + INTEGRATION_PREVIEW_ITEMS, + INTEGRATION_TAB_MIN_ROLE, + INTEGRATION_TABS, + type IntegrationTab, +} from '@/config/integrations' const route = useRoute() const router = useRouter() @@ -211,6 +217,7 @@ type NavItem = { key: string icon: string label: string + emoji?: string children?: Array<{ key: string; label: string }> } @@ -250,12 +257,45 @@ const SECTION_MIN_ROLE: Record = { userprofile: 'viewer', tenant: 'viewer', members: 'viewer', - api: 'owner', } const SYSTEM_ADMIN_SECTIONS = new Set(['system-global']) +const INTEGRATION_SECTION_PREFIX = 'integration-' + +const integrationSectionKey = (tab: IntegrationTab) => `${INTEGRATION_SECTION_PREFIX}${tab}` + +const integrationTabFromSection = (section: string): IntegrationTab => { + const raw = section.startsWith(INTEGRATION_SECTION_PREFIX) + ? section.slice(INTEGRATION_SECTION_PREFIX.length) + : section + if (INTEGRATION_TABS.includes(raw as IntegrationTab)) { + return raw as IntegrationTab + } + return 'im' +} + +const isIntegrationSection = (section: string) => { + return section.startsWith(INTEGRATION_SECTION_PREFIX) && + INTEGRATION_TABS.includes(integrationTabFromSection(section)) +} + +const normalizeSettingsSection = (section: string) => { + if (section === 'api') { + return integrationSectionKey('api') + } + if (section === 'integrations') { + return integrationSectionKey(integrationTabFromSection((route.query.tab as string) || 'im')) + } + return section +} const canSeeSection = (key: string): boolean => { + if (isIntegrationSection(key)) { + const min = INTEGRATION_TAB_MIN_ROLE[integrationTabFromSection(key)] + if (!min) return true + if (authStore.canAccessAllTenants) return true + return authStore.hasRole(min) + } if (SYSTEM_ADMIN_SECTIONS.has(key)) { return authStore.isSystemAdmin } @@ -270,6 +310,12 @@ const navItems = computed(() => { // 一律走 SECTION_MIN_ROLE 表,避免 ad-hoc isAdmin/isOwner 散落在多处。 // 服务端在每条路由上仍以 g.Viewer/Admin/Owner 为准,这里只决定 UI 是 // 否露入口;改动入口规则请同步更新 SECTION_MIN_ROLE 注释里的对照路由。 + const integrationItems: NavItem[] = INTEGRATION_PREVIEW_ITEMS.map((item) => ({ + key: integrationSectionKey(item.key), + icon: item.icon.type === 'icon' ? item.icon.name : 'integration', + emoji: item.icon.type === 'emoji' ? item.icon.value : undefined, + label: t(`integrations.tabs.${item.key}`), + })) const all: NavItem[] = [ { key: 'general', icon: 'setting', label: t('general.title') }, { key: 'ollama', icon: 'server', label: 'Ollama' }, @@ -286,7 +332,7 @@ const navItems = computed(() => { { 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') }, - { key: 'api', icon: 'secured', label: t('settings.apiInfo') }, + ...integrationItems, ] // currentTenantRole 为空表示「membership 还没加载」—— 比起渲染整套 // viewer 入口然后角色一返回又消失,先卡住不渲染更稳,跟原先 members @@ -300,15 +346,15 @@ const navItems = computed(() => { const navGroups = computed(() => { const itemMap = new Map(navItems.value.map((item) => [item.key, item])) const pickItems = (keys: string[]) => keys.map((key) => itemMap.get(key)).filter(Boolean) as NavItem[] - // 分组:账户 → 空间 → 模型 → 数据与扩展 → 平台(文案见 i18n settings.navGroups) - // 关键调整:把个人偏好(general)和个人凭证(api)收进「账户」; + // 分组:账户 → 空间 → 模型 → 发布集成 → 数据与扩展 → 平台(文案见 i18n settings.navGroups) + // 关键调整:把个人偏好(general)和用户信息收进「账户」; // 把空间内功能开关(chathistory)从「平台」挪到「空间」; // 把检索引擎和外部集成合并为「数据与扩展」,避免两个 2~3 项的窄分组。 return [ { key: 'account', label: t('settings.navGroups.account'), - items: pickItems(['general', 'userprofile', 'api']), + items: pickItems(['general', 'userprofile']), }, { key: 'workspace', @@ -320,10 +366,27 @@ const navGroups = computed(() => { label: t('settings.navGroups.modelsRuntime'), items: pickItems(['models', 'ollama', 'weknoracloud']), }, + { + key: 'integrations', + label: t('integrations.title'), + items: pickItems([ + integrationSectionKey('im'), + integrationSectionKey('embed'), + integrationSectionKey('api'), + integrationSectionKey('chrome'), + integrationSectionKey('claw'), + ]), + }, { key: 'data_extensions', label: t('settings.navGroups.dataExtensions'), - items: pickItems(['vectorstore', 'parser', 'storage', 'websearch', 'mcp']), + items: pickItems([ + 'vectorstore', + 'parser', + 'storage', + 'websearch', + 'mcp', + ]), }, { key: 'platform', @@ -350,6 +413,16 @@ const handleNavClick = (item: any) => { // 切换到对应页面 currentSection.value = item.key + if (isIntegrationSection(item.key) && route.path === '/platform/settings') { + router.replace({ + path: '/platform/settings', + query: { + ...route.query, + section: 'integrations', + tab: integrationTabFromSection(item.key), + }, + }) + } } // 子菜单点击处理 @@ -388,8 +461,9 @@ const handleClose = () => { // 监听初始导航设置 watch(() => uiStore.settingsInitialSection, (section) => { if (section && visible.value) { - currentSection.value = section - const navItem = (navItems.value as any[]).find((item) => item.key === section) + const normalizedSection = normalizeSettingsSection(section) + currentSection.value = normalizedSection + const navItem = (navItems.value as any[]).find((item) => item.key === normalizedSection) if (navItem && navItem.children && navItem.children.length > 0) { if (!expandedMenus.value.includes(section)) { expandedMenus.value.push(section) @@ -413,7 +487,7 @@ watch( () => [visible.value, route.query.section], ([isVisible, section]) => { if (!isVisible || typeof section !== 'string') return - currentSection.value = section + currentSection.value = normalizeSettingsSection(section) currentSubSection.value = '' }, { immediate: true }, @@ -439,9 +513,10 @@ const handleEscape = (e: KeyboardEvent) => { const handleSettingsNav = (e: CustomEvent) => { const { section, subsection } = e.detail if (section) { - currentSection.value = section + const normalizedSection = normalizeSettingsSection(section) + currentSection.value = normalizedSection // 如果有子菜单,自动展开 - const navItem = (navItems.value as any[]).find((item: any) => item.key === section) + const navItem = (navItems.value as any[]).find((item: any) => item.key === normalizedSection) if (navItem && navItem.children && navItem.children.length > 0) { if (!expandedMenus.value.includes(section)) { expandedMenus.value.push(section) @@ -601,6 +676,11 @@ onUnmounted(() => { color: inherit; } +.nav-icon-emoji { + font-size: 14px; + line-height: 1; +} + .nav-label { flex: 1; } diff --git a/internal/application/repository/tenant.go b/internal/application/repository/tenant.go index 9e5cafb5..9d026574 100644 --- a/internal/application/repository/tenant.go +++ b/internal/application/repository/tenant.go @@ -3,12 +3,10 @@ package repository import ( "context" "errors" - "strings" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" - "github.com/Tencent/WeKnora/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -116,27 +114,8 @@ func (r *tenantRepository) SearchTenants(ctx context.Context, keyword string, te } // UpdateTenant updates tenant. -// Handles api_key carefully because db.Updates() does not trigger the BeforeSave -// GORM hook. Without this guard, AfterFind-decrypted plaintext would silently -// overwrite the encrypted value in the database. -// -// Strategy: -// - enc:v1:… (pre-encrypted by CreateTenant / UpdateAPIKey): write as-is. -// - plaintext (decrypted by AfterFind): blank it so GORM skips the column. -// - SYSTEM_AES_KEY not set: write as-is (encryption disabled). -// -// The caller's in-memory struct is always restored after the write. func (r *tenantRepository) UpdateTenant(ctx context.Context, tenant *types.Tenant) error { - origAPIKey := tenant.APIKey - if key := utils.GetAESKey(); key != nil && tenant.APIKey != "" && - !strings.HasPrefix(tenant.APIKey, utils.EncPrefix) { - // Plaintext from AfterFind — do not write back; let the DB keep its - // existing encrypted value untouched. - tenant.APIKey = "" - } - err := r.db.WithContext(ctx).Model(&types.Tenant{}).Where("id = ?", tenant.ID).Updates(tenant).Error - tenant.APIKey = origAPIKey - return err + return r.db.WithContext(ctx).Model(&types.Tenant{}).Where("id = ?", tenant.ID).Updates(tenant).Error } // DeleteTenant soft-deletes the tenant and every active membership row diff --git a/internal/application/repository/tenant_api_key.go b/internal/application/repository/tenant_api_key.go new file mode 100644 index 00000000..8687f4e8 --- /dev/null +++ b/internal/application/repository/tenant_api_key.go @@ -0,0 +1,77 @@ +package repository + +import ( + "context" + "errors" + "time" + + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" + "gorm.io/gorm" +) + +var ErrTenantAPIKeyNotFound = errors.New("tenant api key not found") + +type tenantAPIKeyRepository struct { + db *gorm.DB +} + +func NewTenantAPIKeyRepository(db *gorm.DB) interfaces.TenantAPIKeyRepository { + return &tenantAPIKeyRepository{db: db} +} + +func (r *tenantAPIKeyRepository) CreateAPIKey(ctx context.Context, key *types.TenantAPIKey) error { + return r.db.WithContext(ctx).Create(key).Error +} + +func (r *tenantAPIKeyRepository) GetAPIKeyByHash(ctx context.Context, hash string) (*types.TenantAPIKey, error) { + var key types.TenantAPIKey + err := r.db.WithContext(ctx).Session(&gorm.Session{SkipHooks: true}). + Where("key_hash = ?", hash). + First(&key).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrTenantAPIKeyNotFound + } + if err != nil { + return nil, err + } + return &key, nil +} + +func (r *tenantAPIKeyRepository) ListAPIKeys(ctx context.Context, tenantID uint64) ([]*types.TenantAPIKey, error) { + var keys []*types.TenantAPIKey + err := r.db.WithContext(ctx). + Where("tenant_id = ? AND revoked_at IS NULL", tenantID). + 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). + Model(&types.TenantAPIKey{}). + Where("id = ? AND tenant_id = ? AND revoked_at IS NULL", id, tenantID). + 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{}). + Where("id = ? AND revoked_at IS NULL", id). + Update("key_hash", hash).Error +} + +func (r *tenantAPIKeyRepository) UpdateAPIKeyLastUsed(ctx context.Context, id uint64, at time.Time) error { + return r.db.WithContext(ctx). + Model(&types.TenantAPIKey{}). + Where("id = ? AND revoked_at IS NULL", id). + Update("last_used_at", &at).Error +} diff --git a/internal/application/repository/tenant_test.go b/internal/application/repository/tenant_test.go index ed846ad1..4bff1796 100644 --- a/internal/application/repository/tenant_test.go +++ b/internal/application/repository/tenant_test.go @@ -3,18 +3,14 @@ package repository import ( "context" "testing" - "time" "github.com/Tencent/WeKnora/internal/types" - "github.com/Tencent/WeKnora/internal/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) -const testAESKey = "01234567890123456789012345678901" // 32 bytes - // setupTestDB creates an in-memory SQLite database with tenant table. func setupTestDB(t *testing.T) *gorm.DB { t.Helper() @@ -24,149 +20,6 @@ func setupTestDB(t *testing.T) *gorm.DB { return db } -// insertTenantRaw inserts a tenant row with the given api_key value directly, -// bypassing GORM hooks, to simulate an already-encrypted row in the DB. -func insertTenantRaw(t *testing.T, db *gorm.DB, id uint64, apiKey string) { - t.Helper() - now := time.Now() - err := db.Exec( - "INSERT INTO tenants (id, name, api_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - id, "test-tenant", apiKey, "active", now, now, - ).Error - require.NoError(t, err) -} - -// readAPIKeyRaw reads the raw api_key value from the database, bypassing GORM hooks. -func readAPIKeyRaw(t *testing.T, db *gorm.DB, id uint64) string { - t.Helper() - var apiKey string - err := db.Raw("SELECT api_key FROM tenants WHERE id = ?", id).Scan(&apiKey).Error - require.NoError(t, err) - return apiKey -} - -func TestUpdateTenant_PreservesEncryptedAPIKey(t *testing.T) { - t.Setenv("SYSTEM_AES_KEY", testAESKey) - - db := setupTestDB(t) - key := utils.GetAESKey() - require.NotNil(t, key) - - // Encrypt a known api_key and insert it directly into the DB. - originalPlaintext := "sk-my-secret-api-key" - encrypted, err := utils.EncryptAESGCM(originalPlaintext, key) - require.NoError(t, err) - insertTenantRaw(t, db, 1, encrypted) - - // Verify the raw DB value is encrypted. - rawBefore := readAPIKeyRaw(t, db, 1) - assert.True(t, isEncrypted(rawBefore), "api_key should be encrypted in DB before update") - - // Simulate what happens in the application: - // 1. Load tenant (AfterFind decrypts api_key) - var tenant types.Tenant - require.NoError(t, db.First(&tenant, 1).Error) - assert.Equal(t, originalPlaintext, tenant.APIKey, "AfterFind should decrypt api_key") - - // 2. Modify a non-key field (simulating a config update via UpdateTenantKV) - tenant.Description = "updated description" - - // 3. Save via repository's UpdateTenant - repo := NewTenantRepository(db) - require.NoError(t, repo.UpdateTenant(context.Background(), &tenant)) - - // 4. Verify: raw DB value must still be encrypted AND unchanged (no unnecessary re-encryption) - rawAfter := readAPIKeyRaw(t, db, 1) - assert.True(t, isEncrypted(rawAfter), "api_key must remain encrypted in DB after update") - assert.Equal(t, rawBefore, rawAfter, "api_key column should not be touched when only other fields change") - - // 5. Verify: the in-memory struct should still have the decrypted value - assert.Equal(t, originalPlaintext, tenant.APIKey, "caller's struct should retain decrypted value") - - // 6. Verify: round-trip — re-read from DB and decrypt - var reloaded types.Tenant - require.NoError(t, db.First(&reloaded, 1).Error) - assert.Equal(t, originalPlaintext, reloaded.APIKey, "re-loaded api_key should decrypt correctly") - assert.Equal(t, "updated description", reloaded.Description, "description should be updated") -} - -func TestUpdateTenant_PreEncryptedAPIKeyIsWritten(t *testing.T) { - t.Setenv("SYSTEM_AES_KEY", testAESKey) - - db := setupTestDB(t) - key := utils.GetAESKey() - require.NotNil(t, key) - - // Insert a tenant with an initial encrypted api_key. - initialEncrypted, err := utils.EncryptAESGCM("sk-old-key", key) - require.NoError(t, err) - insertTenantRaw(t, db, 4, initialEncrypted) - - // Simulate CreateTenant / UpdateAPIKey path: - // The service layer manually encrypts BEFORE calling repo.UpdateTenant. - newEncrypted, err := utils.EncryptAESGCM("sk-new-key", key) - require.NoError(t, err) - - tenant := &types.Tenant{ID: 4, APIKey: newEncrypted} - repo := NewTenantRepository(db) - require.NoError(t, repo.UpdateTenant(context.Background(), tenant)) - - // The pre-encrypted value should be written to DB as-is. - rawAfter := readAPIKeyRaw(t, db, 4) - assert.Equal(t, newEncrypted, rawAfter, "pre-encrypted api_key should be written to DB") - - // Round-trip: decrypt should yield the new key. - var reloaded types.Tenant - require.NoError(t, db.First(&reloaded, 4).Error) - assert.Equal(t, "sk-new-key", reloaded.APIKey) -} - -func TestUpdateTenant_LegacyPlaintextNotOverwritten(t *testing.T) { - t.Setenv("SYSTEM_AES_KEY", testAESKey) - - db := setupTestDB(t) - - // Insert a tenant with a plaintext api_key (legacy row, pre-encryption era). - insertTenantRaw(t, db, 2, "sk-legacy-plaintext-key") - - // Load tenant — AfterFind returns plaintext as-is (no enc:v1: prefix). - var tenant types.Tenant - require.NoError(t, db.First(&tenant, 2).Error) - assert.Equal(t, "sk-legacy-plaintext-key", tenant.APIKey) - - // Update a non-key field via repository. - tenant.Description = "migrated" - repo := NewTenantRepository(db) - require.NoError(t, repo.UpdateTenant(context.Background(), &tenant)) - - // Legacy plaintext should NOT be overwritten — the column should remain untouched. - rawAfter := readAPIKeyRaw(t, db, 2) - assert.Equal(t, "sk-legacy-plaintext-key", rawAfter, "legacy plaintext api_key should remain untouched") -} - -func TestUpdateTenant_NoEncryptionWithoutAESKey(t *testing.T) { - t.Setenv("SYSTEM_AES_KEY", "") - - db := setupTestDB(t) - insertTenantRaw(t, db, 3, "sk-no-encryption") - - var tenant types.Tenant - require.NoError(t, db.First(&tenant, 3).Error) - assert.Equal(t, "sk-no-encryption", tenant.APIKey) - - tenant.Description = "no key env" - repo := NewTenantRepository(db) - require.NoError(t, repo.UpdateTenant(context.Background(), &tenant)) - - // Without SYSTEM_AES_KEY, api_key should remain as-is (no guard needed). - rawAfter := readAPIKeyRaw(t, db, 3) - assert.Equal(t, "sk-no-encryption", rawAfter) -} - -func isEncrypted(s string) bool { - return len(s) > len(utils.EncPrefix) && s[:len(utils.EncPrefix)] == utils.EncPrefix -} - func TestDeleteTenant_SoftDeletesMemberships(t *testing.T) { db := setupTestDB(t) ctx := context.Background() diff --git a/internal/application/service/tenant.go b/internal/application/service/tenant.go index 02f5ab16..8e149852 100644 --- a/internal/application/service/tenant.go +++ b/internal/application/service/tenant.go @@ -13,11 +13,10 @@ import ( "strings" "time" + werrors "github.com/Tencent/WeKnora/internal/errors" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" - "github.com/Tencent/WeKnora/internal/utils" - werrors "github.com/Tencent/WeKnora/internal/errors" ) var apiKeySecret = func() []byte { @@ -53,8 +52,8 @@ func (s *tenantService) CreateTenant(ctx context.Context, tenant *types.Tenant) logger.Infof(ctx, "Creating tenant, name: %s", tenant.Name) - // Create tenant with initial values - tenant.APIKey = s.generateApiKey(0) + // New tenants do not receive an API key by default. Integrations create + // keys explicitly through tenant_api_keys. tenant.Status = "active" tenant.CreatedAt = time.Now() tenant.UpdatedAt = time.Now() @@ -74,29 +73,7 @@ func (s *tenantService) CreateTenant(ctx context.Context, tenant *types.Tenant) return nil, err } - logger.Infof(ctx, "Tenant created successfully, ID: %d, generating official API Key", tenant.ID) - plaintextAPIKey := s.generateApiKey(tenant.ID) - tenant.APIKey = plaintextAPIKey - - // Manually encrypt APIKey before update, because db.Updates() does not trigger BeforeSave hook - if key := utils.GetAESKey(); key != nil && tenant.APIKey != "" { - if encrypted, err := utils.EncryptAESGCM(tenant.APIKey, key); err == nil { - tenant.APIKey = encrypted - } - } - - if err := s.repo.UpdateTenant(ctx, tenant); err != nil { - logger.ErrorWithFields(ctx, err, map[string]interface{}{ - "tenant_id": tenant.ID, - "tenant_name": tenant.Name, - }) - return nil, err - } - - // Restore plaintext for the response so callers don't see enc:v1: ciphertext. - tenant.APIKey = plaintextAPIKey - - logger.Infof(ctx, "Tenant creation and update completed, ID: %d, name: %s", tenant.ID, tenant.Name) + logger.Infof(ctx, "Tenant created successfully, ID: %d, name: %s", tenant.ID, tenant.Name) return tenant, nil } @@ -151,12 +128,6 @@ func (s *tenantService) UpdateTenant(ctx context.Context, tenant *types.Tenant) return nil, err } - // Generate new API key if empty - if tenant.APIKey == "" { - logger.Info(ctx, "API Key is empty, generating new API Key") - tenant.APIKey = s.generateApiKey(tenant.ID) - } - tenant.UpdatedAt = time.Now() logger.Info(ctx, "Saving tenant information to database") @@ -228,23 +199,8 @@ func (s *tenantService) UpdateAPIKey(ctx context.Context, id uint64) (string, er logger.Infof(ctx, "Generating new API Key for tenant, ID: %d", id) plaintextAPIKey := s.generateApiKey(tenant.ID) - tenant.APIKey = plaintextAPIKey - // Manually encrypt APIKey before update, because db.Updates() does not trigger BeforeSave hook - if key := utils.GetAESKey(); key != nil && tenant.APIKey != "" { - if encrypted, err := utils.EncryptAESGCM(tenant.APIKey, key); err == nil { - tenant.APIKey = encrypted - } - } - - if err := s.repo.UpdateTenant(ctx, tenant); err != nil { - logger.ErrorWithFields(ctx, err, map[string]interface{}{ - "tenant_id": id, - }) - return "", err - } - - logger.Infof(ctx, "Tenant API Key updated successfully, ID: %d", id) + logger.Infof(ctx, "Tenant API Key generated successfully, ID: %d", id) return plaintextAPIKey, nil } diff --git a/internal/application/service/tenant_api_key.go b/internal/application/service/tenant_api_key.go new file mode 100644 index 00000000..ed8b5f52 --- /dev/null +++ b/internal/application/service/tenant_api_key.go @@ -0,0 +1,217 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "time" + + apprepo "github.com/Tencent/WeKnora/internal/application/repository" + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" +) + +type tenantAPIKeyService struct { + repo interfaces.TenantAPIKeyRepository +} + +func NewTenantAPIKeyService(repo interfaces.TenantAPIKeyRepository) interfaces.TenantAPIKeyService { + return &tenantAPIKeyService{repo: repo} +} + +func (s *tenantAPIKeyService) CreateAPIKey( + ctx context.Context, req interfaces.TenantAPIKeyCreateRequest, +) (*interfaces.TenantAPIKeyCreateResult, error) { + if req.TenantID == 0 { + return nil, errors.New("tenant_id is required") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errors.New("name is required") + } + token, err := generateTenantAPIKeyToken() + if err != nil { + return nil, err + } + key := &types.TenantAPIKey{ + TenantID: req.TenantID, + Name: name, + KeyHash: hashTenantAPIKey(token), + APIKey: token, + Scopes: normalizeAPIKeyScopes(req.Scopes), + KnowledgeBaseIDs: normalizeAPIKeyIDs(req.KnowledgeBaseIDs), + ExpiresAt: req.ExpiresAt, + } + if len(key.Scopes) == 0 { + key.Scopes = types.StringArray{types.TenantAPIKeyScopeRead} + } + if err := s.repo.CreateAPIKey(ctx, key); err != nil { + return nil, err + } + return &interfaces.TenantAPIKeyCreateResult{APIKey: key, Token: token}, nil +} + +func (s *tenantAPIKeyService) AuthenticateAPIKey(ctx context.Context, token string) (*types.TenantAPIKey, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, apprepo.ErrTenantAPIKeyNotFound + } + key, err := s.repo.GetAPIKeyByHash(ctx, hashTenantAPIKey(token)) + if err != nil { + return nil, err + } + if key.RevokedAt != nil { + return nil, apprepo.ErrTenantAPIKeyNotFound + } + if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) { + return nil, apprepo.ErrTenantAPIKeyNotFound + } + _ = s.repo.UpdateAPIKeyLastUsed(ctx, key.ID, time.Now()) + return key, nil +} + +func (s *tenantAPIKeyService) AuthenticateTenantAPIKey( + ctx context.Context, tenantID uint64, token string, +) (*types.TenantAPIKey, error) { + token = strings.TrimSpace(token) + if tenantID == 0 || token == "" { + return nil, apprepo.ErrTenantAPIKeyNotFound + } + if key, err := s.AuthenticateAPIKey(ctx, token); err == nil && key != nil { + if key.TenantID != tenantID { + return nil, apprepo.ErrTenantAPIKeyNotFound + } + return key, nil + } else if err != nil && !errors.Is(err, apprepo.ErrTenantAPIKeyNotFound) { + return nil, err + } + keys, err := s.repo.ListAPIKeys(ctx, tenantID) + if err != nil { + return nil, err + } + for _, key := range keys { + if key == nil || subtle.ConstantTimeCompare([]byte(key.APIKey), []byte(token)) != 1 { + continue + } + if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) { + return nil, apprepo.ErrTenantAPIKeyNotFound + } + if err := s.ensureAPIKeyHash(ctx, key); err != nil { + return nil, err + } + _ = s.repo.UpdateAPIKeyLastUsed(ctx, key.ID, time.Now()) + return key, nil + } + return nil, apprepo.ErrTenantAPIKeyNotFound +} + +func (s *tenantAPIKeyService) EnsureTenantAPIKey(ctx context.Context, tenantID uint64, apiKey string) error { + if tenantID == 0 || strings.TrimSpace(apiKey) == "" { + return nil + } + token := strings.TrimSpace(apiKey) + hash := hashTenantAPIKey(token) + if existing, err := s.repo.GetAPIKeyByHash(ctx, hash); err == nil && existing != nil { + return nil + } else if err != nil && !errors.Is(err, apprepo.ErrTenantAPIKeyNotFound) { + return err + } + key := &types.TenantAPIKey{ + TenantID: tenantID, + Name: "Tenant API key", + KeyHash: hash, + APIKey: token, + Scopes: types.StringArray{ + types.TenantAPIKeyScopeRead, + types.TenantAPIKeyScopeWrite, + types.TenantAPIKeyScopeAdmin, + }, + } + return s.repo.CreateAPIKey(ctx, key) +} + +func (s *tenantAPIKeyService) ListAPIKeys(ctx context.Context, tenantID uint64) ([]*types.TenantAPIKey, error) { + keys, err := s.repo.ListAPIKeys(ctx, tenantID) + if err != nil { + return nil, err + } + for _, key := range keys { + if err := s.ensureAPIKeyHash(ctx, key); err != nil { + return nil, err + } + } + return keys, nil +} + +func (s *tenantAPIKeyService) RevokeAPIKey(ctx context.Context, tenantID uint64, id uint64) error { + return s.repo.RevokeAPIKey(ctx, tenantID, id) +} + +func generateTenantAPIKeyToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return "sk-" + base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func hashTenantAPIKey(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (s *tenantAPIKeyService) ensureAPIKeyHash(ctx context.Context, key *types.TenantAPIKey) error { + if key == nil || strings.TrimSpace(key.APIKey) == "" { + return nil + } + hash := hashTenantAPIKey(key.APIKey) + if key.KeyHash == hash { + return nil + } + if err := s.repo.UpdateAPIKeyHash(ctx, key.ID, hash); err != nil { + return err + } + key.KeyHash = hash + return nil +} + +func normalizeAPIKeyScopes(in []string) types.StringArray { + out := types.StringArray{} + seen := map[string]struct{}{} + for _, scope := range in { + scope = strings.ToLower(strings.TrimSpace(scope)) + switch scope { + case types.TenantAPIKeyScopeRead, types.TenantAPIKeyScopeWrite, types.TenantAPIKeyScopeAdmin: + default: + continue + } + if _, ok := seen[scope]; ok { + continue + } + seen[scope] = struct{}{} + out = append(out, scope) + } + return out +} + +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 new file mode 100644 index 00000000..e35ac3f0 --- /dev/null +++ b/internal/application/service/tenant_api_key_test.go @@ -0,0 +1,160 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + apprepo "github.com/Tencent/WeKnora/internal/application/repository" + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" +) + +type fakeTenantAPIKeyRepo struct { + byHash map[string]*types.TenantAPIKey + nextID uint64 +} + +func TestTenantAPIKeyServiceCreateAPIKeyUsesSKPrefix(t *testing.T) { + ctx := context.Background() + repo := newFakeTenantAPIKeyRepo() + svc := NewTenantAPIKeyService(repo) + + result, err := svc.CreateAPIKey(ctx, interfaces.TenantAPIKeyCreateRequest{ + TenantID: 42, + Name: "integration", + Scopes: []string{types.TenantAPIKeyScopeRead}, + }) + if err != nil { + t.Fatalf("CreateAPIKey returned error: %v", err) + } + if !strings.HasPrefix(result.Token, "sk-") { + t.Fatalf("created token = %q, want sk- prefix", result.Token) + } + if result.APIKey.APIKey != result.Token { + t.Fatalf("created api_key = %q, want token %q", result.APIKey.APIKey, result.Token) + } +} + +func newFakeTenantAPIKeyRepo() *fakeTenantAPIKeyRepo { + return &fakeTenantAPIKeyRepo{byHash: map[string]*types.TenantAPIKey{}, nextID: 1} +} + +func (r *fakeTenantAPIKeyRepo) CreateAPIKey(_ context.Context, key *types.TenantAPIKey) error { + if _, ok := r.byHash[key.KeyHash]; ok { + return errors.New("duplicate key hash") + } + cp := *key + cp.ID = r.nextID + r.nextID++ + r.byHash[cp.KeyHash] = &cp + key.ID = cp.ID + return nil +} + +func (r *fakeTenantAPIKeyRepo) GetAPIKeyByHash(_ context.Context, hash string) (*types.TenantAPIKey, error) { + key, ok := r.byHash[hash] + if !ok { + return nil, apprepo.ErrTenantAPIKeyNotFound + } + cp := *key + return &cp, nil +} + +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 { + cp := *key + out = append(out, &cp) + } + } + return out, nil +} + +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 { + key.RevokedAt = &now + return nil + } + } + return apprepo.ErrTenantAPIKeyNotFound +} + +func (r *fakeTenantAPIKeyRepo) UpdateAPIKeyHash(_ context.Context, id uint64, hash string) error { + for oldHash, key := range r.byHash { + if key.ID == id && key.RevokedAt == nil { + delete(r.byHash, oldHash) + key.KeyHash = hash + r.byHash[hash] = key + return nil + } + } + return apprepo.ErrTenantAPIKeyNotFound +} + +func (r *fakeTenantAPIKeyRepo) UpdateAPIKeyLastUsed(_ context.Context, id uint64, at time.Time) error { + for _, key := range r.byHash { + if key.ID == id && key.RevokedAt == nil { + key.LastUsedAt = &at + } + } + return nil +} + +func TestTenantAPIKeyServiceEnsureTenantAPIKeyBackfillsMetadata(t *testing.T) { + ctx := context.Background() + repo := newFakeTenantAPIKeyRepo() + svc := NewTenantAPIKeyService(repo) + token := "sk-42-migrated-secret-value" + + if err := svc.EnsureTenantAPIKey(ctx, 42, token); err != nil { + t.Fatalf("EnsureTenantAPIKey returned error: %v", err) + } + + keys, err := svc.ListAPIKeys(ctx, 42) + if err != nil { + t.Fatalf("ListAPIKeys returned error: %v", err) + } + if len(keys) != 1 { + t.Fatalf("tenant keys count = %d, want 1", len(keys)) + } + key := keys[0] + if key.APIKey != token { + t.Fatalf("tenant key api_key = %q, want %q", key.APIKey, token) + } + if !(types.TenantAPIKeyScope{Scopes: key.Scopes}).HasScope(types.TenantAPIKeyScopeAdmin) { + t.Fatalf("tenant key should include admin scope") + } +} + +func TestTenantAPIKeyServiceAuthenticateTenantAPIKeyRepairsMigratedHash(t *testing.T) { + ctx := context.Background() + repo := newFakeTenantAPIKeyRepo() + svc := NewTenantAPIKeyService(repo) + token := "sk-42-migrated-secret-value" + key := &types.TenantAPIKey{ + TenantID: 42, + Name: "Tenant API key", + KeyHash: "migrated-tenant-42", + APIKey: token, + Scopes: types.StringArray{types.TenantAPIKeyScopeAdmin}, + } + if err := repo.CreateAPIKey(ctx, key); err != nil { + t.Fatalf("CreateAPIKey returned error: %v", err) + } + got, err := svc.AuthenticateTenantAPIKey(ctx, 42, token) + if err != nil { + t.Fatalf("AuthenticateTenantAPIKey returned error: %v", err) + } + if got.KeyHash != hashTenantAPIKey(token) { + t.Fatalf("repaired hash = %q, want %q", got.KeyHash, hashTenantAPIKey(token)) + } + if _, err := svc.AuthenticateAPIKey(ctx, token); err != nil { + t.Fatalf("AuthenticateAPIKey after repair returned error: %v", err) + } +} diff --git a/internal/container/container.go b/internal/container/container.go index ed384235..4fd5a1b6 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -138,6 +138,7 @@ func BuildContainer(container *dig.Container) *dig.Container { // Data repositories layer logger.Debugf(ctx, "[Container] Registering repositories...") must(container.Provide(repository.NewTenantRepository)) + must(container.Provide(repository.NewTenantAPIKeyRepository)) must(container.Provide(repository.NewTenantMemberRepository)) must(container.Provide(repository.NewTenantInvitationRepository)) must(container.Provide(repository.NewAuditLogRepository)) @@ -180,6 +181,7 @@ func BuildContainer(container *dig.Container) *dig.Container { // Business service layer logger.Debugf(ctx, "[Container] Registering business services...") must(container.Provide(service.NewTenantService)) + must(container.Provide(service.NewTenantAPIKeyService)) must(container.Provide(service.NewTenantMemberService)) must(container.Provide(service.NewTenantInvitationService)) must(container.Provide(service.NewAuditLogService)) diff --git a/internal/handler/dto/tenant.go b/internal/handler/dto/tenant.go index 123d5589..23a90b72 100644 --- a/internal/handler/dto/tenant.go +++ b/internal/handler/dto/tenant.go @@ -9,28 +9,26 @@ import ( ) // TenantResponse is the viewer-safe tenant profile shape. Secret-bearing -// columns are omitted or redacted unless the caller has Admin+ (Owner for -// api_key). +// columns are omitted or redacted unless the caller has Admin+. type TenantResponse struct { - ID uint64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - APIKey string `json:"api_key,omitempty"` - Status string `json:"status"` - RetrieverEngines types.RetrieverEngines `json:"retriever_engines"` - Business string `json:"business"` - StorageQuota int64 `json:"storage_quota"` - StorageUsed int64 `json:"storage_used"` - ContextConfig *types.ContextConfig `json:"context_config,omitempty"` - WebSearchConfig *types.WebSearchConfig `json:"web_search_config,omitempty"` - ParserEngineConfig *types.ParserEngineConfig `json:"parser_engine_config,omitempty"` - Credentials *types.CredentialsConfig `json:"credentials,omitempty"` - StorageEngineConfig *types.StorageEngineConfig `json:"storage_engine_config,omitempty"` - ChatHistoryConfig *types.ChatHistoryConfig `json:"chat_history_config,omitempty"` - RetrievalConfig *types.RetrievalConfig `json:"retrieval_config,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"deleted_at"` + ID uint64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + RetrieverEngines types.RetrieverEngines `json:"retriever_engines"` + Business string `json:"business"` + StorageQuota int64 `json:"storage_quota"` + StorageUsed int64 `json:"storage_used"` + ContextConfig *types.ContextConfig `json:"context_config,omitempty"` + WebSearchConfig *types.WebSearchConfig `json:"web_search_config,omitempty"` + ParserEngineConfig *types.ParserEngineConfig `json:"parser_engine_config,omitempty"` + Credentials *types.CredentialsConfig `json:"credentials,omitempty"` + StorageEngineConfig *types.StorageEngineConfig `json:"storage_engine_config,omitempty"` + ChatHistoryConfig *types.ChatHistoryConfig `json:"chat_history_config,omitempty"` + RetrievalConfig *types.RetrievalConfig `json:"retrieval_config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"deleted_at"` } // NewTenantResponse converts a stored tenant into its HTTP response shape. @@ -45,8 +43,6 @@ func NewTenantResponseWithRole(tenant *types.Tenant, role types.TenantRole) *Ten return nil } includeSecrets := role.HasPermission(types.TenantRoleAdmin) - includeAPIKey := RoleCanViewTenantAPIKey(role) - resp := &TenantResponse{ ID: tenant.ID, Name: tenant.Name, @@ -63,9 +59,6 @@ func NewTenantResponseWithRole(tenant *types.Tenant, role types.TenantRole) *Ten UpdatedAt: tenant.UpdatedAt, DeletedAt: tenant.DeletedAt, } - if includeAPIKey { - resp.APIKey = tenant.APIKey - } if includeSecrets { resp.WebSearchConfig = types.WebSearchConfigForResponse(tenant.WebSearchConfig, true) resp.ParserEngineConfig = types.ParserEngineConfigForResponse(tenant.ParserEngineConfig, true) diff --git a/internal/handler/knowledgebase.go b/internal/handler/knowledgebase.go index 16be3774..ae925d4b 100644 --- a/internal/handler/knowledgebase.go +++ b/internal/handler/knowledgebase.go @@ -602,6 +602,7 @@ func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) { } kbs = filtered } + kbs = filterKnowledgeBasesForAPIKeyScope(ctx, kbs) // `all` mode: authoritative server-side capability filter so a client // that bypassed the frontend (old tab, curl, rogue plugin) can't @ a @@ -669,6 +670,7 @@ func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) { } kbs = filtered } + kbs = filterKnowledgeBasesForAPIKeyScope(ctx, kbs) // Get share counts for all knowledge bases if len(kbs) > 0 && h.kbShareService != nil { @@ -701,6 +703,20 @@ func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) { }) } +func filterKnowledgeBasesForAPIKeyScope(ctx context.Context, kbs []*types.KnowledgeBase) []*types.KnowledgeBase { + scope, ok := types.TenantAPIKeyScopeFromContext(ctx) + if !ok || len(scope.KnowledgeBaseIDs) == 0 { + return kbs + } + filtered := make([]*types.KnowledgeBase, 0, len(kbs)) + for _, kb := range kbs { + if kb != nil && scope.AllowsKnowledgeBase(kb.ID) { + filtered = append(filtered, kb) + } + } + return filtered +} + // enrichKBCreatorNames 把 KB 列表里的 CreatorID 批量解析成展示名(username // 优先,退化到 email)。任意一步失败都吞掉错误:creator_name 缺失只会影响 // 卡片右下角的徽章展示,不该影响列表本身可用。 diff --git a/internal/handler/session/qa.go b/internal/handler/session/qa.go index 4ffda8cf..09a26ac9 100644 --- a/internal/handler/session/qa.go +++ b/internal/handler/session/qa.go @@ -129,6 +129,9 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo // Merge @mentioned items into knowledge_base_ids and knowledge_ids kbIDs, knowledgeIDs := mergeKnowledgeTargets(request.KnowledgeBaseIDs, request.KnowledgeIds, request.MentionedItems) + if err := authorizeTenantAPIKeyKnowledgeTargets(ctx, kbIDs, knowledgeIDs); err != nil { + return nil, nil, err + } // The built-in wiki fixer is invoked from a KB page, not from a tenant's // regular agent picker. When the KB is shared, run it in the source tenant @@ -530,6 +533,10 @@ func (h *Handler) SearchKnowledge(c *gin.Context) { c.Error(errors.NewBadRequestError("At least one knowledge_base_id, knowledge_base_ids, knowledge_ids, or scoped tag must be provided")) return } + if err := authorizeTenantAPIKeyKnowledgeTargets(ctx, knowledgeBaseIDs, request.KnowledgeIDs); err != nil { + c.Error(err) + return + } logger.Infof( ctx, @@ -555,6 +562,20 @@ func (h *Handler) SearchKnowledge(c *gin.Context) { }) } +func authorizeTenantAPIKeyKnowledgeTargets(ctx context.Context, kbIDs []string, knowledgeIDs []string) error { + scope, ok := types.TenantAPIKeyScopeFromContext(ctx) + if !ok || !scope.IsKnowledgeBaseRestricted() { + return nil + } + if len(knowledgeIDs) > 0 { + return errors.NewForbiddenError("API key scope does not allow knowledge_ids without a verified knowledge base") + } + if !scope.AllowsKnowledgeBases(kbIDs) { + return errors.NewForbiddenError("API key scope does not allow one or more knowledge bases") + } + return nil +} + // KnowledgeQA godoc // @Summary 知识问答 // @Description 基于知识库的问答(使用LLM总结),支持SSE流式响应 diff --git a/internal/handler/tenant.go b/internal/handler/tenant.go index 0d0bbf8f..fb8ef3cf 100644 --- a/internal/handler/tenant.go +++ b/internal/handler/tenant.go @@ -25,6 +25,7 @@ import ( // through the REST API endpoints type TenantHandler struct { service interfaces.TenantService + apiKeyService interfaces.TenantAPIKeyService userService interfaces.UserService memberService interfaces.TenantMemberService kbService interfaces.KnowledgeBaseService @@ -55,6 +56,7 @@ type TenantHandler struct { // stays focused on business logic. func NewTenantHandler( service interfaces.TenantService, + apiKeyService interfaces.TenantAPIKeyService, userService interfaces.UserService, memberService interfaces.TenantMemberService, kbService interfaces.KnowledgeBaseService, @@ -63,6 +65,7 @@ func NewTenantHandler( ) *TenantHandler { return &TenantHandler{ service: service, + apiKeyService: apiKeyService, userService: userService, memberService: memberService, kbService: kbService, @@ -130,6 +133,29 @@ type apiPrincipalTestTokenResponse struct { ExternalUserID string `json:"external_user_id"` } +type tenantAPIKeyCreateRequest struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + ExpiresAt *int64 `json:"expires_at_unix"` +} + +type tenantAPIKeyResponse struct { + ID uint64 `json:"id"` + Name string `json:"name"` + APIKey string `json:"api_key"` + Scopes types.StringArray `json:"scopes"` + KnowledgeBaseIDs types.StringArray `json:"knowledge_base_ids"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type tenantAPIKeyCreateResponse struct { + tenantAPIKeyResponse + Token string `json:"token"` +} + const ( defaultAPIPrincipalDirectHeader = "X-External-User-ID" defaultAPIPrincipalTokenHeader = "X-External-User-Token" @@ -210,8 +236,9 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) { } else { // Self-service path: a regular user can only set name and // description. Everything else is server-generated by - // TenantService.CreateTenant (api_key, status="active", - // storage_quota default, retriever engines from RETRIEVE_DRIVER). + // TenantService.CreateTenant (status="active", storage_quota + // default, retriever engines from RETRIEVE_DRIVER). API keys are + // created explicitly through the integration API-key list. var req createTenantRequest if err := c.ShouldBindJSON(&req); err != nil { logger.Error(ctx, "Failed to parse request parameters", err) @@ -531,7 +558,19 @@ func (h *TenantHandler) ResetAPIKey(c *gin.Context) { } logger.Infof(ctx, "Resetting API key for tenant, ID: %d", id) - apiKey, err := h.service.UpdateAPIKey(ctx, id) + if h.apiKeyService == nil { + c.Error(errors.NewInternalServerError("API key service is not configured")) + return + } + result, err := h.apiKeyService.CreateAPIKey(ctx, interfaces.TenantAPIKeyCreateRequest{ + TenantID: id, + Name: "Tenant API key", + Scopes: []string{ + types.TenantAPIKeyScopeRead, + types.TenantAPIKeyScopeWrite, + types.TenantAPIKeyScopeAdmin, + }, + }) if err != nil { if appErr, ok := errors.IsAppError(err); ok { logger.Error(ctx, "Failed to reset API key: application error", appErr) @@ -547,11 +586,142 @@ func (h *TenantHandler) ResetAPIKey(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "api_key": apiKey, + "api_key": result.APIKey.APIKey, }, }) } +func (h *TenantHandler) ListAPIKeys(c *gin.Context) { + ctx := c.Request.Context() + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.Error(errors.NewBadRequestError("Invalid tenant ID")) + return + } + keys, err := h.apiKeyService.ListAPIKeys(ctx, id) + if err != nil { + c.Error(errors.NewInternalServerError("Failed to list API keys").WithDetails(err.Error())) + return + } + resp := make([]tenantAPIKeyResponse, 0, len(keys)) + for _, key := range keys { + resp = append(resp, tenantAPIKeyForResponse(key)) + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": resp}) +} + +func (h *TenantHandler) CreateAPIKey(c *gin.Context) { + ctx := c.Request.Context() + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.Error(errors.NewBadRequestError("Invalid tenant ID")) + return + } + var req tenantAPIKeyCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error())) + return + } + if err := validateTenantAPIKeyRequest(ctx, h.kbService, id, req); err != nil { + c.Error(err) + return + } + var expiresAt *time.Time + if req.ExpiresAt != nil { + t := time.Unix(*req.ExpiresAt, 0) + if !t.After(time.Now()) { + c.Error(errors.NewValidationError("expires_at_unix must be in the future")) + return + } + expiresAt = &t + } + result, err := h.apiKeyService.CreateAPIKey(ctx, interfaces.TenantAPIKeyCreateRequest{ + TenantID: id, + Name: req.Name, + Scopes: req.Scopes, + KnowledgeBaseIDs: req.KnowledgeBaseIDs, + ExpiresAt: expiresAt, + }) + if err != nil { + c.Error(errors.NewInternalServerError("Failed to create API key").WithDetails(err.Error())) + return + } + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "data": tenantAPIKeyCreateResponse{ + tenantAPIKeyResponse: tenantAPIKeyForResponse(result.APIKey), + Token: result.Token, + }, + }) +} + +func (h *TenantHandler) DeleteAPIKey(c *gin.Context) { + ctx := c.Request.Context() + tenantID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.Error(errors.NewBadRequestError("Invalid tenant ID")) + return + } + keyID, err := strconv.ParseUint(c.Param("key_id"), 10, 64) + if err != nil || keyID == 0 { + c.Error(errors.NewBadRequestError("Invalid API key ID")) + return + } + if err := h.apiKeyService.RevokeAPIKey(ctx, tenantID, keyID); err != nil { + c.Error(errors.NewNotFoundError("API key not found")) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func tenantAPIKeyForResponse(key *types.TenantAPIKey) tenantAPIKeyResponse { + if key == nil { + return tenantAPIKeyResponse{} + } + return tenantAPIKeyResponse{ + ID: key.ID, + Name: key.Name, + APIKey: key.APIKey, + Scopes: key.Scopes, + KnowledgeBaseIDs: key.KnowledgeBaseIDs, + LastUsedAt: key.LastUsedAt, + ExpiresAt: key.ExpiresAt, + CreatedAt: key.CreatedAt, + } +} + +func validateTenantAPIKeyRequest( + ctx context.Context, + kbService interfaces.KnowledgeBaseService, + tenantID uint64, + req tenantAPIKeyCreateRequest, +) *errors.AppError { + if strings.TrimSpace(req.Name) == "" { + return errors.NewValidationError("name is required") + } + for _, scope := range req.Scopes { + switch strings.ToLower(strings.TrimSpace(scope)) { + case types.TenantAPIKeyScopeRead, types.TenantAPIKeyScopeWrite, types.TenantAPIKeyScopeAdmin: + default: + return errors.NewValidationError("scopes must contain only read, write, or admin") + } + } + for _, kbID := range req.KnowledgeBaseIDs { + kbID = strings.TrimSpace(kbID) + if kbID == "" { + continue + } + kb, err := kbService.GetKnowledgeBaseByID(ctx, kbID) + if err != nil || kb == nil { + return errors.NewValidationError("knowledge_base_ids contains an unknown knowledge base") + } + if kb.TenantID != tenantID { + return errors.NewForbiddenError("knowledge_base_ids contains a knowledge base outside this tenant") + } + } + return nil +} + func apiPrincipalConfigForResponse(cfg *types.APIPrincipalConfig) apiPrincipalConfigResponse { if cfg == nil { cfg = &types.APIPrincipalConfig{} diff --git a/internal/handler/tenant_secret_disclosure_test.go b/internal/handler/tenant_secret_disclosure_test.go index b6986860..de797aa5 100644 --- a/internal/handler/tenant_secret_disclosure_test.go +++ b/internal/handler/tenant_secret_disclosure_test.go @@ -44,7 +44,7 @@ func (s *stubTenantService) SearchTenants(context.Context, string, uint64, int, return nil, 0, nil } func (s *stubTenantService) UpdateAPIKey(context.Context, uint64) (string, error) { return "", nil } -func (s *stubTenantService) ExtractTenantIDFromAPIKey(string) (uint64, error) { return 0, nil } +func (s *stubTenantService) ExtractTenantIDFromAPIKey(string) (uint64, error) { return 0, nil } func (s *stubTenantService) BulkSetStorageQuota(context.Context, int64) (int64, error) { return 0, nil } @@ -127,8 +127,8 @@ func TestGetTenantKVAdminReturnsRedactedSecrets(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) var payload struct { - Success bool `json:"success"` - Data types.ParserEngineConfig `json:"data"` + Success bool `json:"success"` + Data types.ParserEngineConfig `json:"data"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) assert.Equal(t, types.RedactedSecretPlaceholder, payload.Data.MinerUAPIKey) @@ -137,9 +137,8 @@ func TestGetTenantKVAdminReturnsRedactedSecrets(t *testing.T) { func secretTenantFixture() *types.Tenant { return &types.Tenant{ - ID: 42, - Name: "tenant", - APIKey: "tenant-api-key-123", + ID: 42, + Name: "tenant", WebSearchConfig: &types.WebSearchConfig{ APIKey: "legacy-search-secret-999", }, @@ -169,7 +168,7 @@ func TestPutTenantParserConfigAdminPreservesRedactedSecrets(t *testing.T) { tenant := secretTenantFixture() engine := newTenantHandlerTestEngine(t, types.TenantRoleAdmin, tenant) - body := `{"mineru_api_key":"***","mineru_endpoint":"http://new-endpoint"}` + body := `{"mineru_api_key":"***","mineru_endpoint":"https://example.com/mineru"}` rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/tenants/kv/parser-engine-config", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") @@ -177,5 +176,5 @@ func TestPutTenantParserConfigAdminPreservesRedactedSecrets(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) require.NotNil(t, tenant.ParserEngineConfig) assert.Equal(t, "parser-secret-123", tenant.ParserEngineConfig.MinerUAPIKey) - assert.Equal(t, "http://new-endpoint", tenant.ParserEngineConfig.MinerUEndpoint) + assert.Equal(t, "https://example.com/mineru", tenant.ParserEngineConfig.MinerUEndpoint) } diff --git a/internal/middleware/api_key_scope.go b/internal/middleware/api_key_scope.go new file mode 100644 index 00000000..81c80983 --- /dev/null +++ b/internal/middleware/api_key_scope.go @@ -0,0 +1,149 @@ +package middleware + +import ( + "context" + stderrors "errors" + "net/http" + "strings" + + "github.com/Tencent/WeKnora/internal/types" +) + +var errTenantAPIKeyScopeForbidden = stderrors.New("tenant api key scope forbidden") + +func authorizeTenantAPIKeyOperation(ctx context.Context, method string) error { + if _, ok := types.TenantAPIKeyScopeFromContext(ctx); !ok { + return nil + } + return nil +} + +func authorizeTenantAPIKeyRoute(ctx context.Context, method, path string) error { + scope, ok := types.TenantAPIKeyScopeFromContext(ctx) + if !ok { + return nil + } + if strings.Contains(path, "/api-keys") { + return errTenantAPIKeyScopeForbidden + } + if isSafeHTTPMethod(method) { + if allowsTenantAPIKeyRead(scope) { + return nil + } + return errTenantAPIKeyScopeForbidden + } + if isTenantAPIKeyReadUnsafePath(method, path) && allowsTenantAPIKeyRead(scope) { + return nil + } + if isKnowledgeScopedUnsafePath(path) && allowsTenantAPIKeyWrite(scope) { + return nil + } + if allowsTenantAPIKeyAdmin(scope) { + return nil + } + return errTenantAPIKeyScopeForbidden +} + +func authorizeTenantAPIKeyKnowledgeBase(ctx context.Context, kbID string) error { + scope, ok := types.TenantAPIKeyScopeFromContext(ctx) + if !ok { + return nil + } + if scope.AllowsKnowledgeBase(kbID) { + return nil + } + return errTenantAPIKeyScopeForbidden +} + +func isSafeHTTPMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + default: + return false + } +} + +func isKnowledgeScopedUnsafePath(path string) bool { + denyPrefixes := []string{ + "/api/v1/knowledge-bases/copy", + "/api/v1/knowledge/tags", + "/api/v1/knowledge/batch-reparse", + "/api/v1/knowledge/batch-delete", + "/api/v1/knowledge/move", + } + for _, prefix := range denyPrefixes { + if path == prefix || strings.HasPrefix(path, prefix+"/") { + return false + } + } + + if path == "/api/v1/sessions" { + return true + } + allowPrefixes := []string{ + "/api/v1/knowledge-bases/", + "/api/v1/knowledge/", + "/api/v1/chunks/", + "/api/v1/knowledgebase/", + "/api/v1/knowledge-search", + "/api/v1/knowledge-chat/", + } + for _, prefix := range allowPrefixes { + if path == prefix || strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +func allowsTenantAPIKeyRead(scope types.TenantAPIKeyScope) bool { + scope = scope.Normalize() + return len(scope.Scopes) == 0 || + scope.HasScope(types.TenantAPIKeyScopeRead) || + scope.HasScope(types.TenantAPIKeyScopeWrite) || + scope.HasScope(types.TenantAPIKeyScopeAdmin) +} + +func allowsTenantAPIKeyWrite(scope types.TenantAPIKeyScope) bool { + scope = scope.Normalize() + return len(scope.Scopes) == 0 || + scope.HasScope(types.TenantAPIKeyScopeWrite) || + scope.HasScope(types.TenantAPIKeyScopeAdmin) +} + +func allowsTenantAPIKeyAdmin(scope types.TenantAPIKeyScope) bool { + scope = scope.Normalize() + return len(scope.Scopes) == 0 || scope.HasScope(types.TenantAPIKeyScopeAdmin) +} + +func isTenantAPIKeyReadUnsafePath(method, path string) bool { + if method != http.MethodPost { + return false + } + if path == "/api/v1/sessions" || + path == "/api/v1/knowledge-search" || + path == "/api/v1/messages/search" || + path == "/api/v1/chunker/preview" { + return true + } + readPrefixes := []string{ + "/api/v1/knowledge-chat/", + "/api/v1/agent-chat/", + } + for _, prefix := range readPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + readSuffixes := []string{ + "/hybrid-search", + "/faq/search", + } + for _, suffix := range readSuffixes { + if strings.HasSuffix(path, suffix) { + return true + } + } + return false +} diff --git a/internal/middleware/api_key_scope_test.go b/internal/middleware/api_key_scope_test.go new file mode 100644 index 00000000..43fdcd90 --- /dev/null +++ b/internal/middleware/api_key_scope_test.go @@ -0,0 +1,123 @@ +package middleware + +import ( + "context" + stderrors "errors" + "net/http" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +func TestTenantAPIKeyScopeReadOnlyAllowsSafeMethods(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Scopes: types.StringArray{types.TenantAPIKeyScopeRead}, + }) + + if err := authorizeTenantAPIKeyOperation(ctx, http.MethodGet); err != nil { + t.Fatalf("read scoped key should allow GET: %v", err) + } + if err := authorizeTenantAPIKeyOperation(ctx, http.MethodHead); err != nil { + t.Fatalf("read scoped key should allow HEAD: %v", err) + } +} + +func TestTenantAPIKeyScopeReadOnlyRejectsUnsafeMethods(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Scopes: types.StringArray{types.TenantAPIKeyScopeRead}, + }) + + if err := authorizeTenantAPIKeyRoute(ctx, http.MethodPost, "/api/v1/knowledge-bases/kb-1/knowledge/file"); !stderrors.Is(err, errTenantAPIKeyScopeForbidden) { + t.Fatalf("read scoped key POST error = %v, want errTenantAPIKeyScopeForbidden", err) + } +} + +func TestTenantAPIKeyScopeReadOnlyAllowsSemanticReadPost(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Scopes: types.StringArray{types.TenantAPIKeyScopeRead}, + }) + + if err := authorizeTenantAPIKeyRoute(ctx, http.MethodPost, "/api/v1/knowledge-search"); err != nil { + t.Fatalf("read scoped key should allow semantic read POST: %v", err) + } +} + +func TestTenantAPIKeyScopeAllowsScopedKnowledgeBase(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + KnowledgeBaseIDs: types.StringArray{"kb-1"}, + }) + + if err := authorizeTenantAPIKeyKnowledgeBase(ctx, "kb-1"); err != nil { + t.Fatalf("key scoped to kb-1 should allow kb-1: %v", err) + } + if err := authorizeTenantAPIKeyKnowledgeBase(ctx, "kb-2"); !stderrors.Is(err, errTenantAPIKeyScopeForbidden) { + t.Fatalf("key scoped to kb-1 should reject kb-2, got %v", err) + } +} + +func TestTenantAPIKeyRouteRejectsAPIKeyManagement(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Scopes: types.StringArray{types.TenantAPIKeyScopeAdmin}, + }) + + err := authorizeTenantAPIKeyRoute(ctx, http.MethodPost, "/api/v1/tenants/1/api-keys") + if !stderrors.Is(err, errTenantAPIKeyScopeForbidden) { + t.Fatalf("api key management error = %v, want errTenantAPIKeyScopeForbidden", err) + } +} + +func TestTenantAPIKeyRouteRejectsTenantWriteForKnowledgeRestrictedKey(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Scopes: types.StringArray{types.TenantAPIKeyScopeWrite}, + KnowledgeBaseIDs: types.StringArray{"kb-1"}, + }) + + err := authorizeTenantAPIKeyRoute(ctx, http.MethodPut, "/api/v1/tenants/kv/theme") + if !stderrors.Is(err, errTenantAPIKeyScopeForbidden) { + t.Fatalf("tenant write error = %v, want errTenantAPIKeyScopeForbidden", err) + } +} + +func TestTenantAPIKeyRouteRejectsTenantWriteForWriteScope(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Scopes: types.StringArray{types.TenantAPIKeyScopeWrite}, + }) + + err := authorizeTenantAPIKeyRoute(ctx, http.MethodPut, "/api/v1/tenants/1") + if !stderrors.Is(err, errTenantAPIKeyScopeForbidden) { + t.Fatalf("tenant write error = %v, want errTenantAPIKeyScopeForbidden", err) + } +} + +func TestTenantAPIKeyRouteAllowsTenantWriteForAdminScope(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Scopes: types.StringArray{types.TenantAPIKeyScopeAdmin}, + }) + + if err := authorizeTenantAPIKeyRoute(ctx, http.MethodPut, "/api/v1/tenants/kv/theme"); err != nil { + t.Fatalf("admin scoped key should allow tenant management route: %v", err) + } +} + +func TestTenantAPIKeyRouteAllowsKnowledgeWriteForKnowledgeRestrictedKey(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Scopes: types.StringArray{types.TenantAPIKeyScopeWrite}, + KnowledgeBaseIDs: types.StringArray{"kb-1"}, + }) + + if err := authorizeTenantAPIKeyRoute(ctx, http.MethodPut, "/api/v1/knowledge-bases/kb-1"); err != nil { + t.Fatalf("scoped knowledge write should pass route layer: %v", err) + } +} + +func TestTenantAPIKeyRouteRejectsCrossKnowledgeBatchWriteForKnowledgeRestrictedKey(t *testing.T) { + ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{ + Scopes: types.StringArray{types.TenantAPIKeyScopeWrite}, + KnowledgeBaseIDs: types.StringArray{"kb-1"}, + }) + + err := authorizeTenantAPIKeyRoute(ctx, http.MethodPost, "/api/v1/knowledge/batch-delete") + if !stderrors.Is(err, errTenantAPIKeyScopeForbidden) { + t.Fatalf("batch write error = %v, want errTenantAPIKeyScopeForbidden", err) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index e18b99c9..27513a2b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -2,7 +2,6 @@ package middleware import ( "context" - "crypto/subtle" "encoding/json" "errors" "fmt" @@ -83,6 +82,7 @@ func Auth( tenantService interfaces.TenantService, userService interfaces.UserService, memberService interfaces.TenantMemberService, + apiKeyService interfaces.TenantAPIKeyService, cfg *config.Config, ) gin.HandlerFunc { return func(c *gin.Context) { @@ -211,92 +211,60 @@ func Auth( // 尝试X-API-Key认证(兼容模式) apiKey := c.GetHeader("X-API-Key") if apiKey != "" { - // Get tenant information - tenantID, err := tenantService.ExtractTenantIDFromAPIKey(apiKey) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "error": "Unauthorized: invalid API key format", - }) - c.Abort() - return - } - - // Verify API key validity (matches the one in database) - t, err := tenantService.GetTenantByID(c.Request.Context(), tenantID) - if err != nil { - log.Printf("Error getting tenant by ID: %v, tenantID: %d", err, tenantID) - c.JSON(http.StatusUnauthorized, gin.H{ - "error": "Unauthorized: invalid API key", - }) - c.Abort() - return - } - - if t == nil || subtle.ConstantTimeCompare([]byte(t.APIKey), []byte(apiKey)) != 1 { - c.JSON(http.StatusUnauthorized, gin.H{ - "error": "Unauthorized: invalid API key", - }) - c.Abort() - return - } - - // 存储租户和用户信息到上下文 - c.Set(types.TenantIDContextKey.String(), tenantID) - c.Set(types.TenantInfoContextKey.String(), t) - - ctx := context.WithValue( - context.WithValue(c.Request.Context(), types.TenantIDContextKey, tenantID), - types.TenantInfoContextKey, t, - ) - - // 通过 TenantID 关联查询用户;找不到时构造系统虚拟用户, - // 确保所有依赖 UserContextKey 的下游 handler 正常工作。 - user, err := userService.GetUserByTenantID(c.Request.Context(), tenantID) - if err != nil || user == nil { - // Synthetic user. The "system-" shape is recognised - // by types.IsSyntheticUserID, which RBAC service-layer code - // uses to skip recording these IDs as a resource creator. - // Do NOT change the prefix or numeric suffix without - // updating that helper, otherwise KB/Agent CreatorID will - // silently start pointing at the synthetic user again. - 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, + if apiKeyService != nil { + if key, err := apiKeyService.AuthenticateAPIKey(c.Request.Context(), apiKey); err == nil && key != nil { + attachAPIKeyAuthContext(c, tenantService, userService, key.TenantID, key) + if c.IsAborted() { + return + } + if err := authorizeTenantAPIKeyOperation(c.Request.Context(), c.Request.Method); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: API key scope does not allow this operation"}) + c.Abort() + return + } + if err := authorizeTenantAPIKeyRoute(c.Request.Context(), c.Request.Method, c.Request.URL.Path); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: API key scope does not allow this route"}) + c.Abort() + return + } + c.Next() + return } - log.Printf("No user found for tenant %d via API key, using synthetic system user %s", tenantID, user.ID) - } - // API-Key 走的是程序化全租户访问,固定授予 Admin 角色:可以做几乎所有事情, - // 但保留 Owner-only 操作(删除租户、修改租户级配置)的边界。 - // - // 显式拒绝 SystemAdmin:API key 通常被存放在 CI / IaC / sidecar 里, - // 泄露面比 JWT 大得多。即便 key 关联的 user 在 DB 里恰好是 SystemAdmin - // (例如部署里只有一个用户、自己创建了 tenant 又生成了 API key), - // 也绝不允许通过这条通道走平台级管理操作(promote/revoke、全局设置)。 - // 平台管理必须走交互式 JWT 登录,留下可追责的人类身份。 - 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), - }) + tenantID, err := tenantService.ExtractTenantIDFromAPIKey(apiKey) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Unauthorized: invalid API key format", + }) + c.Abort() + return + } + if key, err := apiKeyService.AuthenticateTenantAPIKey( + c.Request.Context(), tenantID, apiKey, + ); err == nil && key != nil { + attachAPIKeyAuthContext(c, tenantService, userService, tenantID, key) + if c.IsAborted() { + return + } + if err := authorizeTenantAPIKeyOperation(c.Request.Context(), c.Request.Method); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: API key scope does not allow this operation"}) + c.Abort() + return + } + if err := authorizeTenantAPIKeyRoute(c.Request.Context(), c.Request.Method, c.Request.URL.Path); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: API key scope does not allow this route"}) + c.Abort() + return + } + c.Next() + return + } + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid API key"}) c.Abort() return } - c.Set(types.PrincipalContextKey.String(), principal) - c.Set(types.TenantRoleContextKey.String(), types.TenantRoleAdmin) - c.Set(types.SystemAdminContextKey.String(), false) - ctx = context.WithValue(ctx, types.UserContextKey, user) - ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID) - ctx = types.WithPrincipal(ctx, principal) - ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin) - ctx = context.WithValue(ctx, types.SystemAdminContextKey, false) - c.Request = c.Request.WithContext(ctx) - c.Next() + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: API key service is not configured"}) + c.Abort() return } @@ -306,6 +274,66 @@ func Auth( } } +func attachAPIKeyAuthContext( + c *gin.Context, + tenantService interfaces.TenantService, + userService interfaces.UserService, + tenantID uint64, + key *types.TenantAPIKey, +) { + t, err := tenantService.GetTenantByID(c.Request.Context(), tenantID) + if err != nil { + log.Printf("Error getting tenant by ID: %v, tenantID: %d", err, tenantID) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid API key"}) + c.Abort() + return + } + + c.Set(types.TenantIDContextKey.String(), tenantID) + c.Set(types.TenantInfoContextKey.String(), t) + ctx := context.WithValue( + context.WithValue(c.Request.Context(), types.TenantIDContextKey, tenantID), + 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, + } + 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) + c.Set(types.TenantRoleContextKey.String(), types.TenantRoleAdmin) + c.Set(types.SystemAdminContextKey.String(), false) + ctx = context.WithValue(ctx, types.UserContextKey, user) + ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID) + ctx = types.WithPrincipal(ctx, principal) + ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin) + ctx = context.WithValue(ctx, types.SystemAdminContextKey, false) + if key != nil { + ctx = types.WithTenantAPIKeyScope(ctx, types.TenantAPIKeyScope{ + KeyID: key.ID, + Scopes: key.Scopes, + KnowledgeBaseIDs: key.KnowledgeBaseIDs, + }) + } + c.Request = c.Request.WithContext(ctx) +} + func resolveAPIPrincipal(ctx context.Context, tenant *types.Tenant, header http.Header) (types.Principal, error) { tenantID := uint64(0) if tenant != nil { diff --git a/internal/middleware/kb_access.go b/internal/middleware/kb_access.go index a42cc08a..733b78ff 100644 --- a/internal/middleware/kb_access.go +++ b/internal/middleware/kb_access.go @@ -241,6 +241,11 @@ func RequireKBAccess( } ctx := c.Request.Context() + if err := authorizeTenantAPIKeyKnowledgeBase(ctx, kbID); err != nil { + _ = c.Error(apperrors.NewForbiddenError("API key scope does not allow this knowledge base")) + c.Abort() + return + } // Rollout window: enforcement off -> log the would-be check and // pass through. We still resolve the KB (best-effort) so the diff --git a/internal/router/router.go b/internal/router/router.go index ce7c8df0..fa35e408 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -51,6 +51,7 @@ type RouterParams struct { KnowledgeHandler *handler.KnowledgeHandler TenantHandler *handler.TenantHandler TenantService interfaces.TenantService + TenantAPIKeyService interfaces.TenantAPIKeyService TenantMemberService interfaces.TenantMemberService TenantMemberHandler *handler.TenantMemberHandler TenantInvitationHandler *handler.TenantInvitationHandler @@ -157,7 +158,7 @@ func NewRouter(params RouterParams) *gin.Engine { RegisterEmbedPublicRoutes(r, params.EmbedChannelHandler, params.EmbedChannelService, params.TenantService, params.RedisClient, params.FileService) // 认证中间件 - r.Use(middleware.Auth(params.TenantService, params.UserService, params.TenantMemberService, params.Config)) + r.Use(middleware.Auth(params.TenantService, params.UserService, params.TenantMemberService, params.TenantAPIKeyService, params.Config)) // 文件服务:统一代理本地/MinIO/COS/TOS存储后端(需要认证) serveFiles(r, params.FileService) @@ -571,6 +572,9 @@ func RegisterTenantRoutes( tenantByID.PUT("", g.Owner(), handler.UpdateTenant) tenantByID.DELETE("", g.Owner(), handler.DeleteTenant) tenantByID.POST("/api-key", g.Owner(), handler.ResetAPIKey) + 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) tenantByID.GET("/api-principal-config", g.Owner(), handler.GetAPIPrincipalConfig) tenantByID.PUT("/api-principal-config", g.Owner(), handler.UpdateAPIPrincipalConfig) tenantByID.POST("/api-principal-test-token", g.Owner(), handler.CreateAPIPrincipalTestToken) diff --git a/internal/types/const.go b/internal/types/const.go index cf4cab7f..0ba31df6 100644 --- a/internal/types/const.go +++ b/internal/types/const.go @@ -18,6 +18,8 @@ const ( UserIDContextKey ContextKey = "UserID" // PrincipalContextKey is the context key for the terminal caller principal. PrincipalContextKey ContextKey = "Principal" + // TenantAPIKeyScopeContextKey carries per-API-key operation and KB scopes. + TenantAPIKeyScopeContextKey ContextKey = "TenantAPIKeyScope" // TenantRoleContextKey is the context key for the caller's TenantRole // in the currently active tenant (loaded by the auth middleware from // the tenant_members table). See TenantRoleFromContext. diff --git a/internal/types/interfaces/tenant.go b/internal/types/interfaces/tenant.go index 6bf8575d..50e05758 100644 --- a/internal/types/interfaces/tenant.go +++ b/internal/types/interfaces/tenant.go @@ -2,6 +2,7 @@ package interfaces import ( "context" + "time" "github.com/Tencent/WeKnora/internal/types" ) @@ -64,3 +65,34 @@ type TenantRepository interface { // BulkSetStorageQuota — see TenantService.BulkSetStorageQuota. BulkSetStorageQuota(ctx context.Context, quotaBytes int64) (int64, error) } + +type TenantAPIKeyCreateRequest struct { + TenantID uint64 + Name string + Scopes []string + KnowledgeBaseIDs []string + ExpiresAt *time.Time +} + +type TenantAPIKeyCreateResult struct { + APIKey *types.TenantAPIKey + Token string +} + +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) + RevokeAPIKey(ctx context.Context, tenantID uint64, id uint64) error + UpdateAPIKeyHash(ctx context.Context, id uint64, hash string) error + UpdateAPIKeyLastUsed(ctx context.Context, id uint64, at time.Time) error +} + +type TenantAPIKeyService interface { + CreateAPIKey(ctx context.Context, req TenantAPIKeyCreateRequest) (*TenantAPIKeyCreateResult, error) + AuthenticateAPIKey(ctx context.Context, token string) (*types.TenantAPIKey, error) + AuthenticateTenantAPIKey(ctx context.Context, tenantID uint64, token string) (*types.TenantAPIKey, error) + EnsureTenantAPIKey(ctx context.Context, tenantID uint64, apiKey string) error + ListAPIKeys(ctx context.Context, tenantID uint64) ([]*types.TenantAPIKey, error) + RevokeAPIKey(ctx context.Context, tenantID uint64, id uint64) error +} diff --git a/internal/types/tenant.go b/internal/types/tenant.go index 0cccfe0c..c4d669a5 100644 --- a/internal/types/tenant.go +++ b/internal/types/tenant.go @@ -90,8 +90,6 @@ type Tenant struct { Name string `yaml:"name" json:"name"` // Description Description string `yaml:"description" json:"description"` - // API key - APIKey string `yaml:"api_key" json:"api_key"` // Status Status string `yaml:"status" json:"status" gorm:"default:'active'"` // Retriever engines @@ -147,31 +145,6 @@ func (t *Tenant) BeforeCreate(tx *gorm.DB) error { return nil } -// BeforeSave encrypts APIKey before persisting to database. -// Uses tx.Statement.SetColumn to avoid polluting the in-memory struct. -func (t *Tenant) BeforeSave(tx *gorm.DB) error { - if key := utils.GetAESKey(); key != nil && t.APIKey != "" { - if encrypted, err := utils.EncryptAESGCM(t.APIKey, key); err == nil { - tx.Statement.SetColumn("api_key", encrypted) - } - } - return nil -} - -// AfterFind decrypts APIKey after loading from database. -// Legacy plaintext (without enc:v1: prefix) is returned as-is. When the value -// is encrypted but SYSTEM_AES_KEY is missing/rotated and the data cannot be -// decrypted, the error is propagated so the read fails loudly instead of -// returning ciphertext to callers. -func (t *Tenant) AfterFind(tx *gorm.DB) error { - decrypted, err := utils.DecryptStoredSecret(t.APIKey) - if err != nil { - return fmt.Errorf("decrypt tenants.api_key (id=%d): %w", t.ID, err) - } - t.APIKey = decrypted - return nil -} - // Value implements the driver.Valuer interface, used to convert RetrieverEngines to database value func (c RetrieverEngines) Value() (driver.Value, error) { return json.Marshal(c) diff --git a/internal/types/tenant_api_key.go b/internal/types/tenant_api_key.go new file mode 100644 index 00000000..7b55cf1d --- /dev/null +++ b/internal/types/tenant_api_key.go @@ -0,0 +1,179 @@ +package types + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/Tencent/WeKnora/internal/utils" + "gorm.io/gorm" +) + +const ( + TenantAPIKeyScopeRead = "read" + TenantAPIKeyScopeWrite = "write" + TenantAPIKeyScopeAdmin = "admin" +) + +// 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. +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:''"` + Scopes StringArray `json:"scopes" gorm:"type:jsonb;not null;default:'[]'"` + KnowledgeBaseIDs StringArray `json:"knowledge_base_ids" gorm:"type:jsonb;not null;default:'[]'"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty" gorm:"index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (TenantAPIKey) TableName() string { + return "tenant_api_keys" +} + +func (k *TenantAPIKey) BeforeSave(tx *gorm.DB) error { + if key := utils.GetAESKey(); key != nil && k.APIKey != "" { + if encrypted, err := utils.EncryptAESGCM(k.APIKey, key); err == nil { + tx.Statement.SetColumn("api_key", encrypted) + } + } + return nil +} + +func (k *TenantAPIKey) AfterFind(tx *gorm.DB) error { + decrypted, err := utils.DecryptStoredSecret(k.APIKey) + if err != nil { + return fmt.Errorf("decrypt tenant_api_keys.api_key (id=%d): %w", k.ID, err) + } + k.APIKey = decrypted + return nil +} + +// TenantAPIKeyScope is the request-context projection used by middleware. +type TenantAPIKeyScope struct { + KeyID uint64 + Scopes StringArray + KnowledgeBaseIDs StringArray +} + +func WithTenantAPIKeyScope(ctx context.Context, scope TenantAPIKeyScope) context.Context { + return context.WithValue(ctx, TenantAPIKeyScopeContextKey, scope.Normalize()) +} + +func TenantAPIKeyScopeFromContext(ctx context.Context) (TenantAPIKeyScope, bool) { + if ctx == nil { + return TenantAPIKeyScope{}, false + } + scope, ok := ctx.Value(TenantAPIKeyScopeContextKey).(TenantAPIKeyScope) + if !ok { + return TenantAPIKeyScope{}, false + } + return scope.Normalize(), true +} + +func (s TenantAPIKeyScope) Normalize() TenantAPIKeyScope { + return TenantAPIKeyScope{ + KeyID: s.KeyID, + Scopes: normalizeScopeArray(s.Scopes), + KnowledgeBaseIDs: normalizeIDArray(s.KnowledgeBaseIDs), + } +} + +func (s TenantAPIKeyScope) HasScope(scope string) bool { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + return false + } + for _, item := range s.Normalize().Scopes { + if item == scope { + return true + } + } + return false +} + +func (s TenantAPIKeyScope) AllowsUnsafeOperation() bool { + s = s.Normalize() + if len(s.Scopes) == 0 { + return true + } + return s.HasScope(TenantAPIKeyScopeWrite) || s.HasScope(TenantAPIKeyScopeAdmin) +} + +func (s TenantAPIKeyScope) AllowsKnowledgeBase(kbID string) bool { + kbID = strings.TrimSpace(kbID) + if kbID == "" { + return false + } + s = s.Normalize() + if len(s.KnowledgeBaseIDs) == 0 { + return true + } + for _, allowed := range s.KnowledgeBaseIDs { + if allowed == kbID { + return true + } + } + return false +} + +func (s TenantAPIKeyScope) IsKnowledgeBaseRestricted() bool { + return len(s.Normalize().KnowledgeBaseIDs) > 0 +} + +func (s TenantAPIKeyScope) AllowsKnowledgeBases(kbIDs []string) bool { + s = s.Normalize() + if len(s.KnowledgeBaseIDs) == 0 { + return true + } + if len(kbIDs) == 0 { + return false + } + for _, kbID := range kbIDs { + if !s.AllowsKnowledgeBase(kbID) { + return false + } + } + return true +} + +func normalizeScopeArray(in StringArray) StringArray { + out := make(StringArray, 0, len(in)) + seen := map[string]struct{}{} + for _, item := range in { + item = strings.TrimSpace(strings.ToLower(item)) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} + +func normalizeIDArray(in StringArray) StringArray { + out := make(StringArray, 0, len(in)) + seen := map[string]struct{}{} + for _, item := range in { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} diff --git a/migrations/mysql/00-init-db.sql b/migrations/mysql/00-init-db.sql index d6634bd0..2cde7712 100644 --- a/migrations/mysql/00-init-db.sql +++ b/migrations/mysql/00-init-db.sql @@ -10,7 +10,6 @@ CREATE TABLE tenants ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, - api_key VARCHAR(256) NOT NULL, retriever_engines JSON NOT NULL, status VARCHAR(50) DEFAULT 'active', business VARCHAR(255) NOT NULL, diff --git a/migrations/paradedb/00-init-db.sql b/migrations/paradedb/00-init-db.sql index 7df315cd..73802249 100644 --- a/migrations/paradedb/00-init-db.sql +++ b/migrations/paradedb/00-init-db.sql @@ -10,7 +10,6 @@ CREATE TABLE IF NOT EXISTS tenants ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, - api_key VARCHAR(256) NOT NULL, retriever_engines JSONB NOT NULL DEFAULT '[]', status VARCHAR(50) DEFAULT 'active', business VARCHAR(255) NOT NULL, @@ -28,7 +27,6 @@ COMMENT ON COLUMN tenants.agent_config IS 'Tenant-level agent configuration in J ALTER SEQUENCE tenants_id_seq RESTART WITH 10000; -- Add indexes -CREATE INDEX IF NOT EXISTS idx_tenants_api_key ON tenants(api_key); CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status); -- Create model table diff --git a/migrations/sqlite/000000_init.down.sql b/migrations/sqlite/000000_init.down.sql index 60ca3be3..fa7dc91b 100644 --- a/migrations/sqlite/000000_init.down.sql +++ b/migrations/sqlite/000000_init.down.sql @@ -1,4 +1,5 @@ DROP TABLE IF EXISTS tenant_invitations; +DROP TABLE IF EXISTS tenant_api_keys; DROP TABLE IF EXISTS user_kb_pins; DROP TABLE IF EXISTS user_resource_favorites; DROP TABLE IF EXISTS tenant_disabled_shared_agents; diff --git a/migrations/sqlite/000000_init.up.sql b/migrations/sqlite/000000_init.up.sql index 0f03ad8d..f917fb0b 100644 --- a/migrations/sqlite/000000_init.up.sql +++ b/migrations/sqlite/000000_init.up.sql @@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS tenants ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL, description TEXT, - api_key VARCHAR(256) NOT NULL, retriever_engines TEXT NOT NULL DEFAULT '[]', status VARCHAR(50) DEFAULT 'active', business VARCHAR(255) NOT NULL, @@ -24,7 +23,6 @@ CREATE TABLE IF NOT EXISTS tenants ( deleted_at DATETIME ); -CREATE INDEX IF NOT EXISTS idx_tenants_api_key ON tenants(api_key); CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status); CREATE TABLE IF NOT EXISTS models ( @@ -751,3 +749,22 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_vector_stores_name_tenant CREATE INDEX IF NOT EXISTS idx_vector_stores_tenant_id ON vector_stores(tenant_id); CREATE INDEX IF NOT EXISTS idx_vector_stores_engine_type ON vector_stores(engine_type); CREATE INDEX IF NOT EXISTS idx_vector_stores_deleted_at ON vector_stores(deleted_at); + +CREATE TABLE IF NOT EXISTS 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 '', + scopes TEXT NOT NULL DEFAULT '[]', + knowledge_base_ids 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 +); + +CREATE INDEX IF NOT EXISTS idx_tenant_api_keys_tenant ON tenant_api_keys(tenant_id); +CREATE INDEX IF NOT EXISTS idx_tenant_api_keys_revoked_at ON tenant_api_keys(revoked_at); diff --git a/migrations/versioned/000065_tenant_api_keys.down.sql b/migrations/versioned/000065_tenant_api_keys.down.sql new file mode 100644 index 00000000..4fcc7c1f --- /dev/null +++ b/migrations/versioned/000065_tenant_api_keys.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS api_key VARCHAR(256) NOT NULL DEFAULT ''; +CREATE INDEX IF NOT EXISTS idx_tenants_api_key ON tenants(api_key); +DROP INDEX IF EXISTS idx_tenant_api_keys_revoked_at; +DROP INDEX IF EXISTS idx_tenant_api_keys_tenant; +DROP TABLE IF EXISTS tenant_api_keys; diff --git a/migrations/versioned/000065_tenant_api_keys.up.sql b/migrations/versioned/000065_tenant_api_keys.up.sql new file mode 100644 index 00000000..657ed6ca --- /dev/null +++ b/migrations/versioned/000065_tenant_api_keys.up.sql @@ -0,0 +1,49 @@ +DO $$ BEGIN RAISE NOTICE '[Migration 000065] Creating tenant_api_keys...'; END $$; + +CREATE TABLE IF NOT EXISTS tenant_api_keys ( + id BIGSERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(128) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, + api_key TEXT NOT NULL DEFAULT '', + scopes JSONB NOT NULL DEFAULT '[]'::jsonb, + knowledge_base_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + last_used_at TIMESTAMP, + expires_at TIMESTAMP, + revoked_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_tenant_api_keys_tenant + ON tenant_api_keys(tenant_id); +CREATE INDEX IF NOT EXISTS idx_tenant_api_keys_revoked_at + ON tenant_api_keys(revoked_at); + +INSERT INTO tenant_api_keys ( + tenant_id, + name, + key_hash, + api_key, + scopes, + knowledge_base_ids, + created_at, + updated_at +) +SELECT + id, + 'Tenant API key', + 'migrated-tenant-' || id::text, + api_key, + '["read","write","admin"]'::jsonb, + '[]'::jsonb, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM tenants +WHERE COALESCE(api_key, '') <> '' +ON CONFLICT (key_hash) DO NOTHING; + +DROP INDEX IF EXISTS idx_tenants_api_key; +ALTER TABLE tenants DROP COLUMN IF EXISTS api_key; + +DO $$ BEGIN RAISE NOTICE '[Migration 000065] tenant_api_keys ready'; END $$;