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.desktopPortDescription') }}
+
+
+
+
+
+
+ {{ $t('tenant.api.desktopPortSave') }}
+
+
-
-
-
-
-
-
-
+
+
+
+
+
{{ $t('tenant.api.desktopBindPublicDescription') }}
+
+
+
+
+
+
+
+
+
+
{{ $t('tenant.api.lanUrlDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $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.createApiKey') }}
+
+
{{ $t('integrations.api.createApiKeyDialogDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $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.retry') }}
-
-
-
-
-
-
-
-
-
-
-
{{ $t('tenant.api.keyDescription') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ $t('tenant.api.urlDescription') }}
-
-
-
-
-
-
-
-
-
-
{{ $t('tenant.api.desktopPortDescription') }}
-
-
-
-
-
-
-
- {{ $t('tenant.api.desktopPortSave') }}
-
-
-
-
-
-
-
-
-
{{ $t('tenant.api.desktopBindPublicDescription') }}
-
-
-
-
-
-
-
-
-
-
{{ $t('tenant.api.lanUrlDescription') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 $$;