feat: add scoped tenant api keys

This commit is contained in:
wizardchen
2026-07-05 10:25:37 +08:00
committed by lyingbug
parent 1708d6895a
commit 505bc7ddec
49 changed files with 2899 additions and 1578 deletions
+95 -2
View File
@@ -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)
-1
View File
@@ -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,
-4
View File
@@ -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
+70 -3
View File
@@ -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/tenantsrouter 上不挂 g.CrossTenant(),自助场景使用)。
*/
export async function createTenant(
+8 -5
View File
@@ -99,9 +99,9 @@
<t-icon name="tools" class="menu-icon" />
<span>{{ $t('settings.mcpService') }}</span>
</div>
<div v-if="canSeeQuickNav('api')" class="menu-item" @click="handleQuickNav('api')">
<div v-if="canSeeQuickNav('integration-api')" class="menu-item" @click="handleQuickNav('integration-api')">
<t-icon name="secured" class="menu-icon" />
<span>{{ $t('settings.apiInfo') }}</span>
<span>{{ $t('integrations.tabs.api') }}</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="handleSettings">
@@ -257,7 +257,7 @@ const QUICKNAV_MIN_ROLE: Record<string, 'viewer' | 'contributor' | 'admin' | 'ow
models: 'viewer',
websearch: 'admin',
mcp: 'admin',
api: 'owner',
'integration-api': 'owner',
}
const canSeeQuickNav = (key: string): boolean => {
if (authStore.canAccessAllTenants) return true
@@ -296,7 +296,11 @@ const toggleMenu = () => {
const handleQuickNav = (section: string) => {
menuVisible.value = false
uiStore.openSettings()
router.push('/platform/settings')
if (section === 'integration-api') {
router.push({ path: '/platform/settings', query: { section: 'integrations', tab: 'api' } })
} else {
router.push('/platform/settings')
}
// 延迟一下,确保设置页面已经渲染
setTimeout(() => {
@@ -556,7 +560,6 @@ const loadUserInfo = async () => {
authStore.setTenant({
id: String(response.data.tenant.id),
name: response.data.tenant.name,
api_key: response.data.tenant.api_key || '',
owner_id: user.id,
created_at: response.data.tenant.created_at,
updated_at: response.data.tenant.updated_at
+3 -71
View File
@@ -85,7 +85,7 @@
<div class="menu_item-box">
<div class="menu_icon">
<img class="icon"
:src="getImgSrc(item.icon == 'zhishiku' ? knowledgeIcon : item.icon == 'agent' ? agentIcon : item.icon == 'integration' ? integrationIcon : item.icon == 'organization' ? organizationIcon : item.icon == 'logout' ? logoutIcon : item.icon == 'setting' ? settingIcon : prefixIcon)"
:src="getImgSrc(item.icon == 'zhishiku' ? knowledgeIcon : item.icon == 'agent' ? agentIcon : item.icon == 'organization' ? organizationIcon : item.icon == 'logout' ? logoutIcon : item.icon == 'setting' ? settingIcon : prefixIcon)"
alt="">
</div>
<template v-if="!uiStore.sidebarCollapsed">
@@ -94,15 +94,6 @@
class="menu-pending-badge"
:title="t('organization.settings.pendingJoinRequestsBadge')">{{
orgStore.totalPendingJoinRequestCount }}</span>
<span v-if="item.path === 'integrations'" class="integration-preview"
aria-hidden="true">
<span v-for="(preview, idx) in integrationPreviewItems" :key="preview.key"
class="integration-preview__item" :style="{ zIndex: idx + 1 }">
<t-icon v-if="preview.icon.type === 'icon'" :name="preview.icon.name"
size="13px" />
<span v-else class="integration-preview__emoji">{{ preview.icon.value }}</span>
</span>
</span>
</template>
</div>
</div>
@@ -257,17 +248,8 @@ import UserMenu from '@/components/UserMenu.vue';
import TenantSelector from '@/components/TenantSelector.vue';
import { useI18n } from 'vue-i18n';
import { getSystemInfo } from '@/api/system';
import { INTEGRATION_PREVIEW_ITEMS, INTEGRATION_TAB_MIN_ROLE } from '@/config/integrations';
const chatResources = useChatResourcesStore();
const integrationPreviewItems = computed(() =>
INTEGRATION_PREVIEW_ITEMS.filter((item) => {
const min = INTEGRATION_TAB_MIN_ROLE[item.key];
if (!min) return true;
if (authStore.canAccessAllTenants) return true;
return authStore.hasRole(min);
}),
);
// Platform logos reused from IMChannelsOverviewPanel — keeps the session list
// visually consistent with the channels admin view.
import wecomLogo from '@/assets/img/im/wecom.svg';
@@ -412,8 +394,6 @@ const isMenuItemActive = (itemPath: string): boolean => {
currentRoute === 'knowledgeBaseSettings';
case 'agents':
return currentRoute === 'agentList';
case 'integrations':
return currentRoute === 'integrations';
case 'organizations':
return currentRoute === 'organizationList';
case 'creatChat':
@@ -444,13 +424,13 @@ const getIconActiveState = (itemPath: string) => {
// 分离上下两部分菜单(使用 visibleMenuArr 以便 lite 模式过滤 logout
const topMenuItems = computed<MenuItem[]>(() => {
return (visibleMenuArr.value as unknown as MenuItem[]).filter((item: MenuItem) =>
item.path === 'knowledge-bases' || item.path === 'agents' || item.path === 'integrations' || item.path === 'organizations' || item.path === 'creatChat'
item.path === 'knowledge-bases' || item.path === 'agents' || item.path === 'organizations' || item.path === 'creatChat'
);
});
const bottomMenuItems = computed<MenuItem[]>(() => {
return (visibleMenuArr.value as unknown as MenuItem[]).filter((item: MenuItem) => {
if (item.path === 'knowledge-bases' || item.path === 'agents' || item.path === 'integrations' || item.path === 'organizations' || item.path === 'creatChat') {
if (item.path === 'knowledge-bases' || item.path === 'agents' || item.path === 'organizations' || item.path === 'creatChat') {
return false;
}
return true;
@@ -1032,7 +1012,6 @@ let prefixIcon = ref('prefixIcon.svg');
let logoutIcon = ref('logout.svg');
let settingIcon = ref('setting.svg');
let agentIcon = ref('agent.svg');
let integrationIcon = ref('integration.svg');
let organizationIcon = ref('organization.svg');
let pathPrefix = ref(route.name)
const getIcon = (path: string) => {
@@ -1041,7 +1020,6 @@ const getIcon = (path: string) => {
const creatChatActiveState = getIconActiveState('creatChat');
const settingsActiveState = getIconActiveState('settings');
const agentsActiveState = route.name === 'agentList';
const integrationsActiveState = route.name === 'integrations';
const organizationsActiveState = route.name === 'organizationList';
// 知识库图标:只在知识库页面显示绿色
@@ -1050,8 +1028,6 @@ const getIcon = (path: string) => {
// 智能体图标:只在智能体页面显示绿色
agentIcon.value = agentsActiveState ? 'agent-green.svg' : 'agent.svg';
integrationIcon.value = integrationsActiveState ? 'integration-green.svg' : 'integration.svg';
// 组织图标:只在组织页面显示绿色
organizationIcon.value = organizationsActiveState ? 'organization-green.svg' : 'organization.svg';
@@ -1076,8 +1052,6 @@ const handleMenuClick = async (path: string) => {
}
} else if (path === 'agents') {
router.push('/platform/agents')
} else if (path === 'integrations') {
router.push('/platform/integrations')
} else if (path === 'organizations') {
// 组织菜单项:跳转到组织列表
router.push('/platform/organizations')
@@ -1906,48 +1880,6 @@ const onDragHandleMouseDown = (e: MouseEvent) => {
flex-shrink: 0;
}
.integration-preview {
display: inline-flex;
align-items: center;
margin-left: auto;
flex-shrink: 0;
width: 0;
overflow: hidden;
pointer-events: none;
.menu_item:hover & {
width: auto;
}
&__item {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 50%;
background: var(--td-bg-color-container);
border: 2px solid var(--td-bg-color-sidebar);
box-sizing: border-box;
color: var(--td-text-color-primary);
&:not(:first-child) {
margin-left: -5px;
}
:deep(.t-icon) {
display: block;
}
}
&__emoji {
font-size: 12px;
line-height: 1;
}
}
.menu_box {
position: relative;
}
+34 -3
View File
@@ -5756,12 +5756,43 @@ export default {
baseUrl: 'API Base URL',
baseUrlDesc: 'Use this base URL with REST API paths.',
apiKey: 'Tenant API Key',
apiKeyDesc: 'The API key is still tenant-level. User identity is supplied by the principal mode below.',
apiKeyDesc: 'Create API keys with operation permissions and knowledge-base scope.',
apiKeys: 'API Keys',
apiKeysDesc: 'Create separate keys per integration and restrict operation permissions plus knowledge-base access.',
createApiKey: 'Create API Key',
createApiKeyDialogDesc: 'Set permissions and available knowledge bases.',
noApiKeys: 'No API keys',
apiKeyName: 'Name',
apiKeyValue: 'API Key',
apiKeyNamePlaceholder: 'Example: MCP read-only access',
apiKeyNameRequired: 'Enter an API key name',
toggleApiKeyVisible: 'Show or hide API key',
apiKeyScopes: 'Permissions',
apiKeyScopeRequired: 'Select at least one permission',
apiKeyKnowledgeScope: 'Knowledge bases',
apiKeyKnowledgeScopePlaceholder: 'Leave empty to allow all knowledge bases',
scopeRead: 'Read',
scopeWrite: 'Write',
scopeAdmin: 'Admin',
scopeReadDesc: 'Query, search, and chat without changing knowledge-base content.',
scopeWriteDesc: 'Create, update, or delete knowledge-base content within the selected scope.',
scopeAdminDesc: 'Manage tenant-level settings; API keys cannot manage API keys.',
allKnowledgeBases: 'All knowledge bases',
createdAt: 'Created',
actions: 'Actions',
deleteApiKey: 'Delete',
deleteApiKeyConfirm: 'After deletion this API key is revoked immediately and deployed integrations using it will stop working.',
deleteApiKeySuccess: 'API key deleted',
deleteApiKeyFailed: 'Failed to delete API key',
createApiKeyFailed: 'Failed to create API key',
loadApiKeysFailed: 'Failed to load API keys',
apiKeyCreated: 'API key created',
apiKeyCreatedDesc: 'The API key has been created and can be viewed or copied from the list.',
principalMode: 'User identity mode',
principalModeDesc:
'Choose how API requests identify the end user. This identity scopes both conversation sessions and MCP tool authorization per user, without reducing the API key\'s tenant-admin access.',
'Choose how API requests identify the end user. This identity scopes both conversation sessions and MCP tool authorization per user.',
principalScope:
'End-user identity isolates conversation sessions and MCP tool authorization (OAuth) per user. The API key can still access all knowledge bases in the tenant; API permissions are not split per external user.',
'End-user identity isolates sessions and MCP OAuth. Operation permissions and knowledge-base scope are controlled by API Keys above.',
modeTenant: 'Tenant only',
modeDirect: 'Direct user ID',
modeSigned: 'Signed token',
+34 -3
View File
@@ -5769,12 +5769,43 @@ export default {
baseUrl: "API 地址",
baseUrlDesc: "与 REST API 路径拼接使用。",
apiKey: "租户 API Key",
apiKeyDesc: "API Key 仍按租户级管理;终端用户身份由下方模式提供。",
apiKeyDesc: "为集成创建带权限和知识库范围的 API Key。",
apiKeys: "API Keys",
apiKeysDesc: "为不同集成创建独立 Key,并限制操作权限与可访问知识库范围。",
createApiKey: "创建 API Key",
createApiKeyDialogDesc: "设置访问权限和可用知识库范围。",
noApiKeys: "暂无 API Key",
apiKeyName: "名称",
apiKeyValue: "API Key",
apiKeyNamePlaceholder: "例如:MCP 只读访问",
apiKeyNameRequired: "请输入 API Key 名称",
toggleApiKeyVisible: "显示或隐藏 API Key",
apiKeyScopes: "权限",
apiKeyScopeRequired: "请至少选择一个权限",
apiKeyKnowledgeScope: "知识库范围",
apiKeyKnowledgeScopePlaceholder: "留空表示允许访问全部知识库",
scopeRead: "读取",
scopeWrite: "写入",
scopeAdmin: "管理",
scopeReadDesc: "查询、检索和对话,不修改知识库内容。",
scopeWriteDesc: "创建、更新或删除知识库内容,受知识库范围限制。",
scopeAdminDesc: "管理租户级配置;不能用 API Key 管理 API Key。",
allKnowledgeBases: "全部知识库",
createdAt: "创建时间",
actions: "操作",
deleteApiKey: "删除",
deleteApiKeyConfirm: "删除后该 API Key 会立即失效,已部署的集成将无法继续使用它。",
deleteApiKeySuccess: "API Key 已删除",
deleteApiKeyFailed: "删除 API Key 失败",
createApiKeyFailed: "创建 API Key 失败",
loadApiKeysFailed: "加载 API Key 列表失败",
apiKeyCreated: "API Key 已创建",
apiKeyCreatedDesc: "API Key 已创建,可在列表中查看或复制。",
principalMode: "用户身份模式",
principalModeDesc:
"配置 API 请求如何识别终端用户。该身份会同时用于区分不同用户的对话 Session 与 MCP 工具授权,不会改变 API Key 的租户管理员权限。",
"配置 API 请求如何识别终端用户。该身份会同时用于区分不同用户的对话 Session 与 MCP 工具授权。",
principalScope:
"终端用户隔离对话 Session 与 MCP 工具授权(OAuthAPI Key 仍可访问租户内全部知识库等资源,权限范围不按外部用户拆分。",
"终端用户身份负责隔离 Session 与 MCP OAuthAPI Key 自身的操作权限和知识库范围由上方 API Keys 管理。",
modeTenant: "仅租户",
modeDirect: "直接传用户 ID",
modeSigned: "签名 Token",
+7 -4
View File
@@ -130,8 +130,13 @@ const router = createRouter({
},
{
path: "integrations",
name: "integrations",
component: () => import("../views/platform/RoutePlaceholder.vue"),
redirect: (to) => ({
path: "/platform/settings",
query: {
...to.query,
section: "integrations",
},
}),
meta: { requiresInit: true, requiresAuth: true }
},
{
@@ -203,7 +208,6 @@ function persistLoginResponse(authStore: ReturnType<typeof useAuthStore>, respon
authStore.setTenant({
id: String(response.tenant.id) || '',
name: response.tenant.name || '',
api_key: response.tenant.api_key || '',
owner_id: response.user.id || '',
created_at: response.tenant.created_at || new Date().toISOString(),
updated_at: response.tenant.updated_at || new Date().toISOString()
@@ -238,7 +242,6 @@ async function hydrateSessionFromToken(authStore: ReturnType<typeof useAuthStore
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,
+2 -3
View File
@@ -59,7 +59,7 @@ export const useAuthStore = defineStore('auth', () => {
})
const hasValidTenant = computed(() => {
return !!tenant.value && !!tenant.value.api_key
return !!tenant.value && !!tenant.value.id
})
const currentTenantId = computed(() => {
@@ -342,7 +342,6 @@ export const useAuthStore = defineStore('auth', () => {
setTenant({
id: String(tenantSnapshot.id) || '',
name: tenantSnapshot.name || '',
api_key: tenantSnapshot.api_key || '',
owner_id: tenantSnapshot.owner_id || u.id || '',
description: tenantSnapshot.description,
status: tenantSnapshot.status,
@@ -545,4 +544,4 @@ export const useAuthStore = defineStore('auth', () => {
logout,
initFromStorage
}
})
})
-1
View File
@@ -28,7 +28,6 @@ export const useMenuStore = defineStore('menuStore', () => {
},
{ title: '', titleKey: 'menu.knowledgeBase', icon: 'zhishiku', path: 'knowledge-bases' },
{ title: '', titleKey: 'menu.agents', icon: 'agent', path: 'agents' },
{ title: '', titleKey: 'menu.integrations', icon: 'integration', path: 'integrations' },
{ title: '', titleKey: 'menu.organizations', icon: 'organization', path: 'organizations' },
{ title: '', titleKey: 'menu.settings', icon: 'setting', path: 'settings' },
{ title: '', titleKey: 'menu.logout', icon: 'logout', path: 'logout' }
@@ -2183,7 +2183,7 @@ function gotoIntegrations(tab: 'im' | 'embed') {
const agentId = editorAgent.value?.id;
if (!agentId) return;
handleClose();
router.push({ path: '/platform/integrations', query: { agentId, tab } });
router.push({ path: '/platform/settings', query: { section: 'integrations', agentId, tab } });
}
const filteredIntentPlaceholders = computed(() => {
+2 -2
View File
@@ -1144,8 +1144,8 @@ const checkAndOpenEditModal = () => {
if (editId && (section === 'im' || section === 'embed' || section === 'integrations')) {
const tab = section === 'embed' ? 'embed' : 'im'
router.replace({
path: '/platform/integrations',
query: { tab, agentId: editId },
path: '/platform/settings',
query: { section: 'integrations', tab, agentId: editId },
})
return
}
-1
View File
@@ -553,7 +553,6 @@ const persistLoginResponse = async (response: any) => {
authStore.setTenant({
id: String(activeTenant.id) || '',
name: activeTenant.name || '',
api_key: activeTenant.api_key || '',
owner_id: response.user.id || '',
created_at: activeTenant.created_at || new Date().toISOString(),
updated_at: activeTenant.updated_at || new Date().toISOString()
File diff suppressed because it is too large Load Diff
@@ -119,8 +119,8 @@ const openChromeStore = () => {
}
const openApiSettings = () => {
router.push('/platform/knowledge-bases')
uiStore.openSettings('api')
router.push({ path: '/platform/settings', query: { section: 'integrations', tab: 'api' } })
uiStore.openSettings('integration-api')
}
const copyApiUrl = async () => {
@@ -141,8 +141,8 @@ const openClawHub = () => {
}
const openApiSettings = () => {
router.push('/platform/knowledge-bases')
uiStore.openSettings('api')
router.push({ path: '/platform/settings', query: { section: 'integrations', tab: 'api' } })
uiStore.openSettings('integration-api')
}
const copyText = async (text: string, successKey: string) => {
@@ -0,0 +1,127 @@
<template>
<div class="integrations-settings">
<div class="integrations-settings__body" :class="{ 'integrations-settings__body--landing': isLandingSection }">
<div v-if="tab === 'im'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.im.title') }}</h2>
<p class="section-description">
{{ $t('agentEditor.im.description') }}
<a
href="https://github.com/Tencent/WeKnora/blob/main/docs/IM%E9%9B%86%E6%88%90%E5%BC%80%E5%8F%91%E6%96%87%E6%A1%A3.md"
target="_blank"
rel="noopener noreferrer"
class="doc-link"
>
{{ $t('agentEditor.im.docLink') }}
<t-icon name="link" class="link-icon" />
</a>
</p>
</div>
<IMChannelPanel v-model:filter-agent-id="filterAgentId" />
</div>
<div v-if="tab === 'embed'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.embed.title') }}</h2>
<p class="section-description">{{ $t('agentEditor.embed.description') }}</p>
</div>
<AgentEmbedChannelPanel v-model:filter-agent-id="filterAgentId" />
</div>
<div v-if="tab === 'api'" class="section">
<div class="section-header">
<h2>{{ $t('integrations.api.title') }}</h2>
<p class="section-description">{{ $t('integrations.api.subtitle') }}</p>
</div>
<ApiIntegrationSettings />
</div>
<ChromeExtensionLanding v-if="tab === 'chrome'" />
<ClawSkillLanding v-if="tab === 'claw'" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import IMChannelPanel from '@/components/IMChannelPanel.vue'
import AgentEmbedChannelPanel from '@/components/AgentEmbedChannelPanel.vue'
import ApiIntegrationSettings from '@/views/integrations/ApiIntegrationSettings.vue'
import ChromeExtensionLanding from '@/views/integrations/ChromeExtensionLanding.vue'
import ClawSkillLanding from '@/views/integrations/ClawSkillLanding.vue'
import type { IntegrationTab } from '@/config/integrations'
const filterAgentId = ref('')
const props = defineProps<{
tab: IntegrationTab
}>()
const route = useRoute()
const isLandingSection = computed(
() => props.tab === 'chrome' || props.tab === 'claw',
)
function applyAgentFilterFromRoute() {
filterAgentId.value = (route.query.agentId as string) || ''
}
watch(
() => route.query.agentId,
applyAgentFilterFromRoute,
{ immediate: true },
)
</script>
<style scoped lang="less">
.integrations-settings {
display: flex;
flex-direction: column;
}
.integrations-settings__body {
min-width: 0;
}
.integrations-settings__body--landing {
max-width: 760px;
}
.section-header {
margin-bottom: 18px;
h2 {
margin: 0 0 6px;
color: var(--td-text-color-primary);
font-size: 18px;
font-weight: 600;
line-height: 1.35;
}
}
.section-description {
margin: 0;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.doc-link {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
color: var(--td-brand-color);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.link-icon {
font-size: 13px;
}
</style>
@@ -1,394 +0,0 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="settings-overlay" @click.self="handleClose">
<div class="settings-modal">
<div class="settings-container">
<div class="settings-sidebar">
<div class="sidebar-header">
<h2 class="sidebar-title">{{ $t('integrations.title') }}</h2>
<p class="sidebar-subtitle">{{ $t('integrations.subtitle') }}</p>
</div>
<div class="settings-nav">
<div
v-for="item in navItems"
:key="item.key"
:class="['nav-item', { active: currentSection === item.key }]"
@click="currentSection = item.key"
>
<span v-if="item.emoji" class="nav-emoji" role="img" :aria-label="item.label">{{ item.emoji }}</span>
<t-icon v-else :name="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
</div>
</div>
</div>
<div class="settings-content">
<button class="close-btn" @click="handleClose" :aria-label="$t('common.close')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div class="content-wrapper" :class="{ 'content-wrapper--landing': isLandingSection }">
<div v-if="currentSection === 'im'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.im.title') }}</h2>
<p class="section-description">
{{ $t('agentEditor.im.description') }}
<a
href="https://github.com/Tencent/WeKnora/blob/main/docs/IM%E9%9B%86%E6%88%90%E5%BC%80%E5%8F%91%E6%96%87%E6%A1%A3.md"
target="_blank"
rel="noopener noreferrer"
class="doc-link"
>
{{ $t('agentEditor.im.docLink') }}
<t-icon name="link" class="link-icon" />
</a>
</p>
</div>
<IMChannelPanel v-model:filter-agent-id="filterAgentId" />
</div>
<div v-if="currentSection === 'embed'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.embed.title') }}</h2>
<p class="section-description">{{ $t('agentEditor.embed.description') }}</p>
</div>
<AgentEmbedChannelPanel v-model:filter-agent-id="filterAgentId" />
</div>
<div v-if="currentSection === 'api'" class="section">
<div class="section-header">
<h2>{{ $t('integrations.api.title') }}</h2>
<p class="section-description">{{ $t('integrations.api.subtitle') }}</p>
</div>
<ApiIntegrationSettings />
</div>
<ChromeExtensionLanding v-if="currentSection === 'chrome'" />
<ClawSkillLanding v-if="currentSection === 'claw'" />
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import IMChannelPanel from '@/components/IMChannelPanel.vue';
import AgentEmbedChannelPanel from '@/components/AgentEmbedChannelPanel.vue';
import ApiIntegrationSettings from '@/views/integrations/ApiIntegrationSettings.vue';
import ChromeExtensionLanding from '@/views/integrations/ChromeExtensionLanding.vue';
import ClawSkillLanding from '@/views/integrations/ClawSkillLanding.vue';
import {
INTEGRATION_PREVIEW_ITEMS,
INTEGRATION_TAB_MIN_ROLE,
INTEGRATION_TABS,
type IntegrationTab,
} from '@/config/integrations';
import { useAuthStore } from '@/stores/auth';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const currentSection = ref<IntegrationTab>('im');
const filterAgentId = ref('');
const visible = computed(() => route.name === 'integrations');
const isLandingSection = computed(
() => currentSection.value === 'chrome' || currentSection.value === 'claw',
);
function canSeeTab(tab: IntegrationTab): boolean {
const min = INTEGRATION_TAB_MIN_ROLE[tab];
if (!min) return true;
if (authStore.canAccessAllTenants) return true;
return authStore.hasRole(min);
}
const navItems = computed(() =>
INTEGRATION_PREVIEW_ITEMS
.filter((item) => canSeeTab(item.key))
.map((item) => ({
key: item.key,
icon: item.icon.type === 'icon' ? item.icon.name : '',
emoji: item.icon.type === 'emoji' ? item.icon.value : undefined,
label: t(`integrations.tabs.${item.key}`),
})),
);
function applyRouteQuery() {
const tab = route.query.tab as string;
if (INTEGRATION_TABS.includes(tab as IntegrationTab) && canSeeTab(tab as IntegrationTab)) {
currentSection.value = tab as IntegrationTab;
} else if (INTEGRATION_TABS.includes(tab as IntegrationTab)) {
currentSection.value = navItems.value[0]?.key ?? 'im';
if (visible.value) syncRouteQuery();
} else if (navItems.value.length > 0 && !canSeeTab(currentSection.value)) {
currentSection.value = navItems.value[0].key;
if (visible.value) syncRouteQuery();
}
filterAgentId.value = (route.query.agentId as string) || '';
}
function syncRouteQuery() {
const query: Record<string, string> = { tab: currentSection.value };
if (filterAgentId.value) {
query.agentId = filterAgentId.value;
}
router.replace({ path: route.path, query });
}
function handleClose() {
if (route.name !== 'integrations') return;
if (window.history.length > 1) {
router.back();
} else {
router.push('/platform/knowledge-bases');
}
}
watch(visible, (open) => {
if (open) applyRouteQuery();
});
watch(currentSection, () => {
if (visible.value) syncRouteQuery();
});
watch(filterAgentId, () => {
if (visible.value) syncRouteQuery();
});
watch(
() => [route.query.tab, route.query.agentId],
() => {
if (visible.value) applyRouteQuery();
},
);
</script>
<style scoped lang="less">
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.settings-modal {
position: relative;
width: 90vw;
max-width: 1100px;
height: 85vh;
max-height: 750px;
background: var(--td-bg-color-container);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
.settings-content {
position: relative;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
background: var(--td-bg-color-container);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--td-text-color-secondary);
transition: all 0.2s ease;
z-index: 10;
box-shadow: 0 0 0 1px var(--td-component-stroke);
&:hover {
background: var(--td-bg-color-container-hover);
color: var(--td-text-color-primary);
}
}
.content-wrapper {
flex: 1;
overflow-y: auto;
padding: 24px 28px 28px;
&--landing {
padding-right: 52px;
padding-bottom: 20px;
}
}
.settings-container {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
}
.settings-sidebar {
width: 208px;
background-color: var(--td-bg-color-settings-modal);
border-right: 1px solid var(--td-component-stroke);
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px 14px 12px;
border-bottom: 1px solid var(--td-component-stroke);
flex-shrink: 0;
}
.sidebar-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.sidebar-subtitle {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--td-text-color-placeholder);
}
.settings-nav {
flex: 1;
padding: 8px;
overflow-y: auto;
min-height: 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 6px 12px;
margin-bottom: 2px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
color: var(--td-text-color-primary);
&:hover {
background: var(--td-bg-color-container-hover);
}
&.active {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-brand-color);
font-weight: 500;
.nav-icon {
color: var(--td-brand-color);
}
}
}
.nav-icon {
margin-right: 8px;
font-size: 16px;
color: var(--td-text-color-secondary);
flex-shrink: 0;
}
.nav-emoji {
margin-right: 8px;
font-size: 15px;
line-height: 1;
flex-shrink: 0;
width: 16px;
text-align: center;
}
.nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-header {
margin-bottom: 20px;
h2 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--td-text-color-primary);
}
}
.section-description {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--td-text-color-secondary);
.doc-link {
color: var(--td-brand-color);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
&:hover {
text-decoration: underline;
}
}
.link-icon {
font-size: 14px;
}
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
.settings-modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.settings-modal {
transform: scale(0.98);
opacity: 0;
}
}
</style>
-2
View File
@@ -10,7 +10,6 @@
</div>
<!-- 全局设置模态框供所有 platform 子路由使用 -->
<Settings />
<IntegrationsModal />
<!-- 全局命令面板 (K) platform 路由存活 -->
<GlobalCommandPalette />
<!-- 全局右上角"待处理邀请"铃铛固定定位z-index 低于抽屉业务页面
@@ -27,7 +26,6 @@ import { useRoute, useRouter } from 'vue-router'
import useKnowledgeBase from '@/hooks/useKnowledgeBase'
import UploadMask from '@/components/upload-mask.vue'
import Settings from '@/views/settings/Settings.vue'
import IntegrationsModal from '@/views/integrations/IntegrationsModal.vue'
import GlobalCommandPalette from '@/components/GlobalCommandPalette.vue'
import GlobalInvitationBell from '@/components/GlobalInvitationBell.vue'
import NewUserGuide from '@/components/NewUserGuide.vue'
-666
View File
@@ -1,666 +0,0 @@
<template>
<div class="api-info">
<div class="section-header">
<h2>{{ $t('tenant.api.title') }}</h2>
<p class="section-description">{{ $t('tenant.api.description') }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="loading-inline">
<t-loading size="small" />
<span>{{ $t('tenant.loadingInfo') }}</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-inline">
<t-alert theme="error" :message="error">
<template #operation>
<t-button size="small" @click="loadInfo">{{ $t('tenant.retry') }}</t-button>
</template>
</t-alert>
</div>
<!-- Content -->
<div v-else class="settings-group">
<!-- API Key -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.keyLabel') }}</label>
<p class="desc">{{ $t('tenant.api.keyDescription') }}</p>
</div>
<div class="setting-control">
<div class="api-key-control">
<t-input
v-model="displayApiKey"
readonly
type="text"
class="mono-text-input"
style="width: 100%;"
/>
<t-button
size="small"
variant="text"
@click="showApiKey = !showApiKey"
>
<t-icon :name="showApiKey ? 'browse-off' : 'browse'" />
</t-button>
<t-button
size="small"
variant="text"
@click="copyApiKey"
:title="$t('tenant.api.copyTitle')"
>
<t-icon name="file-copy" />
</t-button>
<t-button
v-if="authStore.hasRole('owner')"
size="small"
variant="text"
theme="danger"
:loading="resetting"
:title="$t('tenant.api.resetTitle')"
@click="confirmResetApiKey"
>
<t-icon name="refresh" />
</t-button>
</div>
</div>
</div>
<!-- API base URL -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.urlLabel') }}</label>
<p class="desc">{{ $t('tenant.api.urlDescription') }}</p>
</div>
<div class="setting-control">
<div class="api-key-control">
<t-input
:model-value="apiBaseUrlDisplay"
readonly
type="text"
class="mono-text-input"
style="width: 100%;"
/>
<t-button
size="small"
variant="text"
@click="copyApiUrl"
:title="$t('tenant.api.copyUrlTitle')"
>
<t-icon name="file-copy" />
</t-button>
</div>
</div>
</div>
<!-- Desktop (Wails): fixed local API port + optional LAN/public listen -->
<template v-if="showDesktopPortSetting || showDesktopBindPublicSetting">
<div v-if="showDesktopPortSetting" class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.desktopPortLabel') }}</label>
<p class="desc">{{ $t('tenant.api.desktopPortDescription') }}</p>
</div>
<div class="setting-control">
<div class="api-key-control">
<div class="desktop-port-input-wrap">
<t-input-number
v-model="desktopPortInput"
:min="0"
:max="65535"
theme="normal"
/>
</div>
<t-button size="small" variant="text" @click="saveDesktopPort">
{{ $t('tenant.api.desktopPortSave') }}
</t-button>
</div>
</div>
</div>
<div v-if="showDesktopBindPublicSetting" class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.desktopBindPublicLabel') }}</label>
<p class="desc">{{ $t('tenant.api.desktopBindPublicDescription') }}</p>
</div>
<div class="setting-control desktop-bind-public-control">
<t-switch v-model="desktopBindPublicInput" @change="onDesktopBindPublicChange" />
</div>
</div>
<div v-if="wailsApiLanBaseURL" class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.lanUrlLabel') }}</label>
<p class="desc">{{ $t('tenant.api.lanUrlDescription') }}</p>
</div>
<div class="setting-control">
<div class="api-key-control">
<t-input
:model-value="wailsApiLanBaseURL"
readonly
type="text"
class="mono-text-input"
style="width: 100%;"
/>
<t-button
size="small"
variant="text"
@click="copyLanApiUrl"
:title="$t('tenant.api.lanUrlCopyTitle')"
>
<t-icon name="file-copy" />
</t-button>
</div>
</div>
</div>
<div v-if="showLanUrlUnavailableHint" class="setting-row lan-url-hint-row">
<t-alert theme="warning" :message="$t('tenant.api.lanUrlUnavailable')" />
</div>
</template>
<!-- API docs -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.docLabel') }}</label>
<p class="desc">
{{ $t('tenant.api.docDescription') }}
<a @click="openApiDoc" class="doc-link">
{{ $t('tenant.api.openDoc') }}
<t-icon name="link" class="link-icon" />
</a>
</p>
</div>
</div>
<!-- 用户信息原本嵌在这一页底部 api 信息页是 owner-only要看
api key + reset把用户基本信息id / 用户名 / 邮箱 / 注册
时间也卡在这里意味着 viewer / contributor 看不到自己的账户
信息已拆到独立的 UserProfile.vuesettings viewer 可见 -->
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getCurrentUser, type TenantInfo } from '@/api/auth'
import { resetTenantApiKey } from '@/api/tenant'
import { getApiBaseUrl } from '@/utils/api-base'
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const authStore = useAuthStore()
// Reactive state
const tenantInfo = ref<TenantInfo | null>(null)
const loading = ref(true)
const error = ref('')
const showApiKey = ref(false)
const resetting = ref(false)
/** WeKnora Lite (Wails): real API origin is loopback + dynamic port, not window.location.origin */
const wailsApiBaseURL = ref<string | null>(null)
const showDesktopPortSetting = ref(false)
const showDesktopBindPublicSetting = ref(false)
const desktopPortInput = ref<number | undefined>(0)
const desktopBindPublicInput = ref(false)
const wailsApiLanBaseURL = ref<string | null>(null)
const desktopListenPublicActive = ref(false)
// Computed
const displayApiKey = computed(() => {
if (!tenantInfo.value?.api_key) return ''
if (showApiKey.value) {
return tenantInfo.value.api_key
}
let masked = ''
for (let i = 0; i < tenantInfo.value.api_key.length; i++) {
masked += '•'
}
return masked
})
const showLanUrlUnavailableHint = computed(
() =>
showDesktopBindPublicSetting.value &&
desktopListenPublicActive.value &&
!wailsApiLanBaseURL.value
)
const apiBaseUrlDisplay = computed(() => {
if (wailsApiBaseURL.value) {
return wailsApiBaseURL.value
}
const configured = getApiBaseUrl().trim().replace(/\/$/, '')
let origin = typeof window !== 'undefined' ? window.location.origin : ''
if (!origin || origin === 'null') {
origin = ''
}
const base = configured || origin
return `${base}/api/v1`
})
type WeKnoraDesktopWindow = Window & {
__WEKNORA_API_BASE__?: string
__WEKNORA_API_LAN_BASE__?: string
go?: {
main?: {
App?: {
GetAPIBaseURL?: () => Promise<string> | string
GetAPILanBaseURL?: () => Promise<string> | string
GetDesktopHTTPPortSetting?: () => Promise<number> | number
GetDesktopHTTPBindPublicSetting?: () => Promise<boolean> | boolean
GetDesktopListenPublicActive?: () => Promise<boolean> | boolean
SetDesktopHTTPPortSetting?: (port: number) => Promise<void> | void
SetDesktopHTTPBindPublicSetting?: (v: boolean) => Promise<void> | void
}
}
}
}
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((r) => setTimeout(r, 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 p = await Promise.resolve(win.go!.main!.App!.GetDesktopHTTPPortSetting!())
desktopPortInput.value = typeof p === 'number' ? p : 0
} catch {
desktopPortInput.value = 0
}
}
if (desktopBindPublicBindingsAvailable(win)) {
showDesktopBindPublicSetting.value = true
try {
const b = await Promise.resolve(win.go!.main!.App!.GetDesktopHTTPBindPublicSetting!())
desktopBindPublicInput.value = !!b
} catch {
desktopBindPublicInput.value = false
}
}
}
const onDesktopBindPublicChange = async (value: boolean) => {
const v = value === true
const win = window as WeKnoraDesktopWindow
const fn = win.go?.main?.App?.SetDesktopHTTPBindPublicSetting
if (typeof fn !== 'function') return
try {
await Promise.resolve(fn(v))
MessagePlugin.success(t('tenant.api.desktopBindPublicSaved'))
} catch (err: unknown) {
MessagePlugin.error(err instanceof Error ? err.message : t('tenant.api.desktopBindPublicSaveFailed'))
desktopBindPublicInput.value = !v
}
}
const saveDesktopPort = async () => {
const v = desktopPortInput.value
const port = typeof v === 'number' && !Number.isNaN(v) ? Math.floor(v) : 0
if (port < 0 || port > 65535) {
MessagePlugin.warning(t('tenant.api.desktopPortInvalid'))
return
}
const win = window as WeKnoraDesktopWindow
const fn = win.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'))
}
}
// Methods
const loadInfo = async () => {
try {
loading.value = true
error.value = ''
const userResponse = await getCurrentUser()
if ((userResponse as any).success && userResponse.data) {
tenantInfo.value = userResponse.data.tenant ?? null
} else {
error.value = userResponse.message || t('tenant.messages.fetchFailed')
}
} catch (err: any) {
error.value = err?.message || t('tenant.messages.networkError')
} finally {
loading.value = false
}
}
const openApiDoc = () => {
window.open('https://github.com/Tencent/WeKnora/blob/main/docs/api/README.md', '_blank')
}
const fallbackCopyText = (text: string) => {
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)
}
const confirmResetApiKey = () => {
if (!tenantInfo.value?.id) {
MessagePlugin.warning(t('tenant.api.noKey'))
return
}
const dialog = DialogPlugin.confirm({
header: t('tenant.api.resetConfirmTitle'),
body: t('tenant.api.resetConfirmBody'),
confirmBtn: { content: t('tenant.api.resetConfirmOk'), theme: 'danger' },
cancelBtn: t('tenant.api.resetConfirmCancel'),
onConfirm: async () => {
await performResetApiKey()
dialog.destroy()
},
onClose: () => dialog.destroy(),
})
}
const performResetApiKey = async () => {
if (!tenantInfo.value?.id) return
resetting.value = true
try {
const resp = await resetTenantApiKey(tenantInfo.value.id)
if (resp.success && resp.data?.api_key) {
tenantInfo.value = { ...tenantInfo.value, api_key: resp.data.api_key }
showApiKey.value = true
MessagePlugin.success(t('tenant.api.resetSuccess'))
} else {
MessagePlugin.error(resp.message || t('tenant.api.resetFailed'))
}
} catch (err: any) {
MessagePlugin.error(err?.message || t('tenant.api.resetFailed'))
} finally {
resetting.value = false
}
}
const copyApiKey = async () => {
if (!tenantInfo.value?.api_key) {
MessagePlugin.warning(t('tenant.api.noKey'))
return
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(tenantInfo.value.api_key)
} else {
fallbackCopyText(tenantInfo.value.api_key)
}
MessagePlugin.success(t('tenant.api.copySuccess'))
} catch (err) {
fallbackCopyText(tenantInfo.value.api_key)
MessagePlugin.success(t('tenant.api.copySuccess'))
}
}
const copyLanApiUrl = async () => {
const text = wailsApiLanBaseURL.value
if (!text) return
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
} else {
fallbackCopyText(text)
}
MessagePlugin.success(t('tenant.api.lanUrlCopySuccess'))
} catch {
fallbackCopyText(text)
MessagePlugin.success(t('tenant.api.lanUrlCopySuccess'))
}
}
const copyApiUrl = async () => {
const text = apiBaseUrlDisplay.value
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
} else {
fallbackCopyText(text)
}
MessagePlugin.success(t('tenant.api.urlCopySuccess'))
} catch {
fallbackCopyText(text)
MessagePlugin.success(t('tenant.api.urlCopySuccess'))
}
}
// Lifecycle
onMounted(async () => {
await tryLoadWailsApiBaseURL()
await loadDesktopApiPrefs()
loadInfo()
})
</script>
<style lang="less" scoped>
.api-info {
width: 100%;
}
// TDesign's <t-input> forwards `style=""` to its wrapper but applies
// `font: var(--td-font-body-medium)` (a shorthand) to the real <input>
// inside, which silently resets font-family. Reach into the inner input
// explicitly so the code font actually takes effect for API keys, URLs,
// etc. Scoped via `.mono-text-input` so this only applies where we opt in.
.mono-text-input :deep(input) {
font-family: var(--app-font-family-mono);
font-size: 12px;
}
.section-header {
margin-bottom: 32px;
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
}
.section-description {
font-size: 14px;
color: var(--td-text-color-secondary);
margin: 0;
line-height: 1.5;
}
}
.loading-inline {
display: flex;
align-items: center;
gap: 12px;
padding: 40px 0;
justify-content: center;
color: var(--td-text-color-secondary);
font-size: 14px;
}
.error-inline {
padding: 20px 0;
}
.settings-group {
display: flex;
flex-direction: column;
gap: 0;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
border-bottom: none;
}
}
.setting-info {
flex: 1;
max-width: 65%;
padding-right: 24px;
label {
font-size: 15px;
font-weight: 500;
color: var(--td-text-color-primary);
display: block;
margin-bottom: 4px;
}
.desc {
font-size: 13px;
color: var(--td-text-color-secondary);
margin: 0;
line-height: 1.5;
}
.doc-link {
cursor: pointer;
}
}
.setting-control {
flex-shrink: 0;
min-width: 280px;
display: flex;
justify-content: flex-end;
align-items: flex-start;
.info-value {
font-size: 14px;
color: var(--td-text-color-primary);
text-align: right;
word-break: break-word;
}
}
.api-key-control {
width: 100%;
display: flex;
gap: 8px;
align-items: center;
}
.info-section-title {
font-size: 14px;
font-weight: 600;
color: var(--td-text-color-primary);
margin-top: 24px;
margin-bottom: 12px;
&:first-child {
margin-top: 0;
}
}
/* 与 API Key / API 地址 行一致:输入区占满 flex 剩余宽度,文案按钮贴右 */
.desktop-port-input-wrap {
flex: 1;
min-width: 0;
:deep(.t-input-number) {
width: 100%;
}
:deep(.t-input__wrap) {
width: 100%;
}
:deep(input) {
font-family: var(--app-font-family-mono);
font-size: 12px;
}
}
.desktop-bind-public-control {
padding-top: 4px;
}
.lan-url-hint-row {
padding-top: 0;
border-bottom: 1px solid var(--td-component-stroke);
:deep(.t-alert) {
width: 100%;
}
}
</style>
+97 -17
View File
@@ -44,6 +44,7 @@
<path d="M4.5 5.5L6.5 12.5L9 7.5L11.5 12.5L13.5 5.5" stroke="currentColor" stroke-width="1.3"
stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
<span v-else-if="item.emoji" class="nav-icon nav-icon-emoji">{{ item.emoji }}</span>
<t-icon v-else :name="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
<t-icon v-if="item.children && item.children.length > 0"
@@ -70,7 +71,7 @@
<div class="settings-content">
<div class="content-wrapper" :class="{
'content-wrapper--wide': currentSection === 'members',
'content-wrapper--full': currentSection === 'system-global',
'content-wrapper--full': currentSection === 'system-global' || isIntegrationSection(currentSection),
}">
<!-- 角色不允许访问当前 sectiondeep-link 进来 / 跨租户切换后角色降级 优先于具体 section 渲染
正常导航走 navItems filter 不会到这里 watch(navItems) fallback 会在角色降级
@@ -139,7 +140,6 @@
</div>
<!-- 用户信息账户基础信息ID / 用户名 / 邮箱 / 注册时间
ApiInfo.vue 拆出来原页面挂的是 owner-only 入口
用户的基本信息不该跟 owner 权限绑定 -->
<div v-if="currentSection === 'userprofile'" class="section">
<UserProfile />
@@ -155,9 +155,9 @@
<TenantMembers />
</div>
<!-- API 信息 -->
<div v-if="currentSection === 'api'" class="section">
<ApiInfo />
<!-- 发布集成 -->
<div v-if="isIntegrationSection(currentSection)" class="section">
<IntegrationSettingsSection :tab="integrationTabFromSection(currentSection)" />
</div>
<!-- MCP 服务 -->
@@ -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<string, RoleKey> = {
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<NavGroup[]>(() => {
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<NavGroup[]>(() => {
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;
}
+1 -22
View File
@@ -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
@@ -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
}
@@ -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()
+5 -49
View File
@@ -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
}
@@ -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
}
@@ -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)
}
}
+2
View File
@@ -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))
+19 -26
View File
@@ -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)
+16
View File
@@ -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 缺失只会影响
// 卡片右下角的徽章展示,不该影响列表本身可用。
+21
View File
@@ -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流式响应
+174 -4
View File
@@ -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{}
@@ -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)
}
+149
View File
@@ -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
}
+123
View File
@@ -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)
}
}
+110 -82
View File
@@ -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-<tenantID>" 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 操作(删除租户、修改租户级配置)的边界。
//
// 显式拒绝 SystemAdminAPI 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 {
+5
View File
@@ -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
+5 -1
View File
@@ -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)
+2
View File
@@ -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.
+32
View File
@@ -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
}
-27
View File
@@ -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)
+179
View File
@@ -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
}
-1
View File
@@ -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,
-2
View File
@@ -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
+1
View File
@@ -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;
+19 -2
View File
@@ -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);
@@ -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;
@@ -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 $$;