mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-31 00:50:02 +08:00
feat: add scoped tenant api keys
This commit is contained in:
+95
-2
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface TenantInfo {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
api_key?: string
|
||||
status?: string
|
||||
business?: string
|
||||
storage_quota?: number
|
||||
@@ -49,6 +48,30 @@ export interface APIPrincipalTestToken {
|
||||
external_user_id: string
|
||||
}
|
||||
|
||||
export type TenantAPIKeyScope = 'read' | 'write' | 'admin'
|
||||
|
||||
export interface TenantAPIKey {
|
||||
id: number
|
||||
name: string
|
||||
api_key: string
|
||||
scopes: TenantAPIKeyScope[]
|
||||
knowledge_base_ids: string[]
|
||||
last_used_at?: string
|
||||
expires_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreatedTenantAPIKey extends TenantAPIKey {
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface CreateTenantAPIKeyPayload {
|
||||
name: string
|
||||
scopes: TenantAPIKeyScope[]
|
||||
knowledge_base_ids?: string[]
|
||||
expires_at_unix?: number
|
||||
}
|
||||
|
||||
// 搜索租户参数
|
||||
export interface SearchTenantsParams {
|
||||
keyword?: string
|
||||
@@ -146,6 +169,50 @@ export async function createAPIPrincipalTestToken(
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTenantAPIKeys(
|
||||
tenantId: number,
|
||||
): Promise<{ success: boolean; data?: TenantAPIKey[]; message?: string }> {
|
||||
try {
|
||||
const response = await get(`/api/v1/tenants/${tenantId}/api-keys`)
|
||||
return response as unknown as { success: boolean; data?: TenantAPIKey[]; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.listApiKeysFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTenantAPIKey(
|
||||
tenantId: number,
|
||||
payload: CreateTenantAPIKeyPayload,
|
||||
): Promise<{ success: boolean; data?: CreatedTenantAPIKey; message?: string }> {
|
||||
try {
|
||||
const response = await post(`/api/v1/tenants/${tenantId}/api-keys`, payload)
|
||||
return response as unknown as { success: boolean; data?: CreatedTenantAPIKey; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.createApiKeyFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTenantAPIKey(
|
||||
tenantId: number,
|
||||
keyId: number,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const response = await del(`/api/v1/tenants/${tenantId}/api-keys/${keyId}`)
|
||||
return response as unknown as { success: boolean; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || t('error.tenant.deleteApiKeyFailed'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新租户信息(目前暴露名称、描述两个字段的编辑入口)。
|
||||
* 后端 `PUT /tenants/:id` 用指针字段区分"未传"和"显式空串",未传的列不会
|
||||
@@ -186,8 +253,8 @@ export async function deleteTenant(
|
||||
|
||||
/**
|
||||
* 创建新工作区(任意已登录用户均可调用)。
|
||||
* 后端会自动把调用者写成新租户的 Owner,并生成 api_key、默认 storage_quota
|
||||
* 等服务端字段,所以这里只暴露 name + description。
|
||||
* 后端会自动把调用者写成新租户的 Owner,并填充默认 storage_quota
|
||||
* 等服务端字段;API Key 由用户在集成页手动创建。
|
||||
* 路由:POST /api/v1/tenants(router 上不挂 g.CrossTenant(),自助场景使用)。
|
||||
*/
|
||||
export async function createTenant(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 工具授权(OAuth);API Key 仍可访问租户内全部知识库等资源,权限范围不按外部用户拆分。",
|
||||
"终端用户身份负责隔离 Session 与 MCP OAuth;API Key 自身的操作权限和知识库范围由上方 API Keys 管理。",
|
||||
modeTenant: "仅租户",
|
||||
modeDirect: "直接传用户 ID",
|
||||
modeSigned: "签名 Token",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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.vue(settings 里 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>
|
||||
@@ -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),
|
||||
}">
|
||||
<!-- 角色不允许访问当前 section(deep-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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 缺失只会影响
|
||||
// 卡片右下角的徽章展示,不该影响列表本身可用。
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 操作(删除租户、修改租户级配置)的边界。
|
||||
//
|
||||
// 显式拒绝 SystemAdmin:API key 通常被存放在 CI / IaC / sidecar 里,
|
||||
// 泄露面比 JWT 大得多。即便 key 关联的 user 在 DB 里恰好是 SystemAdmin
|
||||
// (例如部署里只有一个用户、自己创建了 tenant 又生成了 API key),
|
||||
// 也绝不允许通过这条通道走平台级管理操作(promote/revoke、全局设置)。
|
||||
// 平台管理必须走交互式 JWT 登录,留下可追责的人类身份。
|
||||
c.Set(types.UserContextKey.String(), user)
|
||||
c.Set(types.UserIDContextKey.String(), user.ID)
|
||||
principal, principalErr := resolveAPIPrincipal(c.Request.Context(), t, c.Request.Header)
|
||||
if principalErr != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": apiPrincipalAuthErrorMessage(principalErr),
|
||||
})
|
||||
tenantID, err := tenantService.ExtractTenantIDFromAPIKey(apiKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Unauthorized: invalid API key format",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if key, err := apiKeyService.AuthenticateTenantAPIKey(
|
||||
c.Request.Context(), tenantID, apiKey,
|
||||
); err == nil && key != nil {
|
||||
attachAPIKeyAuthContext(c, tenantService, userService, tenantID, key)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
if err := authorizeTenantAPIKeyOperation(c.Request.Context(), c.Request.Method); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: API key scope does not allow this operation"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if err := authorizeTenantAPIKeyRoute(c.Request.Context(), c.Request.Method, c.Request.URL.Path); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: API key scope does not allow this route"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid API key"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(types.PrincipalContextKey.String(), principal)
|
||||
c.Set(types.TenantRoleContextKey.String(), types.TenantRoleAdmin)
|
||||
c.Set(types.SystemAdminContextKey.String(), false)
|
||||
ctx = context.WithValue(ctx, types.UserContextKey, user)
|
||||
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
|
||||
ctx = types.WithPrincipal(ctx, principal)
|
||||
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
|
||||
ctx = context.WithValue(ctx, types.SystemAdminContextKey, false)
|
||||
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: API key service is not configured"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -306,6 +274,66 @@ func Auth(
|
||||
}
|
||||
}
|
||||
|
||||
func attachAPIKeyAuthContext(
|
||||
c *gin.Context,
|
||||
tenantService interfaces.TenantService,
|
||||
userService interfaces.UserService,
|
||||
tenantID uint64,
|
||||
key *types.TenantAPIKey,
|
||||
) {
|
||||
t, err := tenantService.GetTenantByID(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting tenant by ID: %v, tenantID: %d", err, tenantID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid API key"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(types.TenantIDContextKey.String(), tenantID)
|
||||
c.Set(types.TenantInfoContextKey.String(), t)
|
||||
ctx := context.WithValue(
|
||||
context.WithValue(c.Request.Context(), types.TenantIDContextKey, tenantID),
|
||||
types.TenantInfoContextKey, t,
|
||||
)
|
||||
|
||||
user, err := userService.GetUserByTenantID(c.Request.Context(), tenantID)
|
||||
if err != nil || user == nil {
|
||||
user = &types.User{
|
||||
ID: fmt.Sprintf("system-%d", tenantID),
|
||||
Username: fmt.Sprintf("system-%d", tenantID),
|
||||
Email: fmt.Sprintf("system-%d@api-key.local", tenantID),
|
||||
TenantID: tenantID,
|
||||
IsActive: true,
|
||||
}
|
||||
log.Printf("No user found for tenant %d via API key, using synthetic system user %s", tenantID, user.ID)
|
||||
}
|
||||
|
||||
c.Set(types.UserContextKey.String(), user)
|
||||
c.Set(types.UserIDContextKey.String(), user.ID)
|
||||
principal, principalErr := resolveAPIPrincipal(c.Request.Context(), t, c.Request.Header)
|
||||
if principalErr != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": apiPrincipalAuthErrorMessage(principalErr)})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(types.PrincipalContextKey.String(), principal)
|
||||
c.Set(types.TenantRoleContextKey.String(), types.TenantRoleAdmin)
|
||||
c.Set(types.SystemAdminContextKey.String(), false)
|
||||
ctx = context.WithValue(ctx, types.UserContextKey, user)
|
||||
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
|
||||
ctx = types.WithPrincipal(ctx, principal)
|
||||
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
|
||||
ctx = context.WithValue(ctx, types.SystemAdminContextKey, false)
|
||||
if key != nil {
|
||||
ctx = types.WithTenantAPIKeyScope(ctx, types.TenantAPIKeyScope{
|
||||
KeyID: key.ID,
|
||||
Scopes: key.Scopes,
|
||||
KnowledgeBaseIDs: key.KnowledgeBaseIDs,
|
||||
})
|
||||
}
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
func resolveAPIPrincipal(ctx context.Context, tenant *types.Tenant, header http.Header) (types.Principal, error) {
|
||||
tenantID := uint64(0)
|
||||
if tenant != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user