fix(access): 统一 API Key 模型权限与列表契约
将全局启用、用户组基线、API Key 专属或排除规则及 scope 按固定顺序求值,避免 Key 越过所属用户组权限,并让运行时候选与模型列表共用同一权限链。 新增 Key 级可分配模型与失效规则诊断接口、OpenAI 兼容 /v1/models 及 rich 列表迁移路径;前端权限弹窗改为按当前 Key 实时加载并支持清理失效规则。 验证:Go 全量测试与 go vet 通过;Web 22 个测试文件共 142 项通过;pnpm lint、pnpm openapi、pnpm build、Compose 配置、gofmt、ShellCheck 和 git diff --check 通过;独立 PostgreSQL 真实配置验收通过。
This commit is contained in:
+15
-22
@@ -73,7 +73,6 @@ import {
|
||||
listAdminTasks,
|
||||
listAuditLogs,
|
||||
listApiKeyAccessRules,
|
||||
listApiKeyAssignableModels,
|
||||
listApiKeys,
|
||||
listBaseModels,
|
||||
listCatalogProviders,
|
||||
@@ -186,7 +185,6 @@ type DataKey =
|
||||
| 'publicCatalog'
|
||||
| 'playgroundApiKeys'
|
||||
| 'playgroundModels'
|
||||
| 'apiKeyPolicyModels'
|
||||
| 'modelCatalog'
|
||||
| 'networkProxyConfig'
|
||||
| 'clientCustomizationSettings'
|
||||
@@ -241,7 +239,6 @@ export function App() {
|
||||
summary: { modelCount: 0, sourceCount: 0 },
|
||||
});
|
||||
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
||||
const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState<PlatformModel[]>([]);
|
||||
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
|
||||
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
|
||||
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
|
||||
@@ -563,9 +560,6 @@ export function App() {
|
||||
case 'playgroundModels':
|
||||
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
||||
return;
|
||||
case 'apiKeyPolicyModels':
|
||||
setApiKeyPolicyModels((await listApiKeyAssignableModels(nextToken)).items);
|
||||
return;
|
||||
case 'playgroundApiKeys': {
|
||||
const response = await listPlayableApiKeys(nextToken);
|
||||
setApiKeys(response.items);
|
||||
@@ -717,7 +711,7 @@ export function App() {
|
||||
try {
|
||||
const response = await createApiKey(token, {
|
||||
name: apiKeyForm.name,
|
||||
scopes: ['chat', 'embedding', 'rerank', 'image', 'video', 'music', 'audio'],
|
||||
scopes: ['chat', 'embedding', 'rerank', 'image', 'image_vectorize', 'video', 'video_enhance', 'music', 'audio', 'voice_clone'],
|
||||
expiresAt: apiKeyForm.expiresAt ? new Date(apiKeyForm.expiresAt).toISOString() : undefined,
|
||||
});
|
||||
setApiKeySecret(response.secret);
|
||||
@@ -750,7 +744,7 @@ export function App() {
|
||||
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
||||
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
||||
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(input.platformId
|
||||
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
||||
@@ -770,7 +764,7 @@ export function App() {
|
||||
const updated = await updatePlatform(token, platform.id, input);
|
||||
const platformForState = withCredentialPreviewFallback(updated, input, platform);
|
||||
setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。');
|
||||
} catch (err) {
|
||||
@@ -802,7 +796,7 @@ export function App() {
|
||||
platformPriority: state.priority,
|
||||
}
|
||||
: status));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。');
|
||||
} catch (err) {
|
||||
@@ -846,7 +840,7 @@ export function App() {
|
||||
cooldownUntil: undefined,
|
||||
}
|
||||
: model));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('模型运行状态已恢复。');
|
||||
} catch (err) {
|
||||
@@ -863,7 +857,7 @@ export function App() {
|
||||
await deletePlatform(token, platformId);
|
||||
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
||||
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('平台已删除。');
|
||||
} catch (err) {
|
||||
@@ -879,7 +873,7 @@ export function App() {
|
||||
try {
|
||||
const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input);
|
||||
setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。');
|
||||
} catch (err) {
|
||||
@@ -895,7 +889,7 @@ export function App() {
|
||||
try {
|
||||
await deleteTenant(token, tenantId);
|
||||
setTenants((current) => current.filter((tenant) => tenant.id !== tenantId));
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('租户已删除。');
|
||||
} catch (err) {
|
||||
@@ -911,7 +905,7 @@ export function App() {
|
||||
try {
|
||||
const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input);
|
||||
setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(userId ? '用户已更新。' : '用户已创建。');
|
||||
} catch (err) {
|
||||
@@ -967,7 +961,7 @@ export function App() {
|
||||
try {
|
||||
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
|
||||
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
|
||||
} catch (err) {
|
||||
@@ -985,7 +979,7 @@ export function App() {
|
||||
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
||||
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
||||
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('用户组已删除。');
|
||||
} catch (err) {
|
||||
@@ -1039,7 +1033,7 @@ export function App() {
|
||||
try {
|
||||
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
||||
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
||||
} catch (err) {
|
||||
@@ -1055,7 +1049,7 @@ export function App() {
|
||||
try {
|
||||
await deleteAccessRule(token, ruleId);
|
||||
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限规则已删除。');
|
||||
} catch (err) {
|
||||
@@ -1071,7 +1065,7 @@ export function App() {
|
||||
try {
|
||||
const response = await batchAccessRules(token, input);
|
||||
setAccessRules(response.items);
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限已更新。');
|
||||
} catch (err) {
|
||||
@@ -1459,7 +1453,6 @@ export function App() {
|
||||
apiKeyForm={apiKeyForm}
|
||||
apiKeySecret={apiKeySecret}
|
||||
apiKeySecretsById={apiKeySecretsById}
|
||||
apiKeyPolicyModels={apiKeyPolicyModels}
|
||||
data={data}
|
||||
message={coreMessage}
|
||||
section={workspaceSection}
|
||||
@@ -1733,7 +1726,7 @@ function dataKeysForRoute(
|
||||
if (activePage === 'workspace') {
|
||||
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
||||
if (workspaceSection === 'billing') return ['wallet'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'apiKeyPolicyModels'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules'];
|
||||
if (workspaceSection === 'tasks') return ['tasks'];
|
||||
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
|
||||
return [];
|
||||
|
||||
@@ -238,17 +238,17 @@ describe('API Key permission resources', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads the user-owned resource pool independently from playable models', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [] }), {
|
||||
it('loads the key-scoped resource pool independently from playable models', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [], ruleDiagnostics: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await listApiKeyAssignableModels('user-token');
|
||||
await listApiKeyAssignableModels('user-token', 'key-1');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain('/api/v1/api-keys/assignable-models');
|
||||
expect(url).toContain('/api/v1/api-keys/key-1/assignable-models');
|
||||
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer user-token');
|
||||
});
|
||||
});
|
||||
|
||||
+4
-3
@@ -18,6 +18,7 @@ import type {
|
||||
GatewayAccessRuleBatchRequest,
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleUpsertRequest,
|
||||
GatewayAPIKeyAssignableModelsResponse,
|
||||
GatewayApiKey,
|
||||
GatewayApiKeyScopeUpdateRequest,
|
||||
GatewayAuditLog,
|
||||
@@ -159,7 +160,7 @@ export async function listModels(token: string): Promise<ListResponse<PlatformMo
|
||||
}
|
||||
|
||||
export async function listPlayableModels(token: string): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/platform-models', { token });
|
||||
}
|
||||
|
||||
export async function listModelCatalog(token: string): Promise<ModelCatalogResponse> {
|
||||
@@ -461,8 +462,8 @@ export async function listApiKeyAccessRules(token: string): Promise<ListResponse
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules', { token });
|
||||
}
|
||||
|
||||
export async function listApiKeyAssignableModels(token: string): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/api-keys/assignable-models', { token });
|
||||
export async function listApiKeyAssignableModels(token: string, apiKeyId: string): Promise<GatewayAPIKeyAssignableModelsResponse> {
|
||||
return request<GatewayAPIKeyAssignableModelsResponse>(`/api/v1/api-keys/${apiKeyId}/assignable-models`, { token });
|
||||
}
|
||||
|
||||
export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> {
|
||||
|
||||
@@ -8,10 +8,10 @@ describe('ApiDocsPage extended task documentation', () => {
|
||||
it('separates the complete public catalog into common and compatibility interfaces', () => {
|
||||
const endpoints = publicApiCatalogGroups.flatMap((group) => group.endpoints);
|
||||
|
||||
expect(publicApiEndpointCount()).toBe(71);
|
||||
expect(publicApiEndpointCount('open')).toBe(54);
|
||||
expect(publicApiEndpointCount()).toBe(73);
|
||||
expect(publicApiEndpointCount('open')).toBe(56);
|
||||
expect(publicApiEndpointCount('compatibility')).toBe(17);
|
||||
expect(endpoints.every((endpoint) => endpoint.path.startsWith('/api/v1/'))).toBe(true);
|
||||
expect(endpoints.every((endpoint) => endpoint.path.startsWith('/api/v1/') || endpoint.path === '/v1/models')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the common open API catalog as a complete onboarding page', () => {
|
||||
@@ -21,7 +21,7 @@ describe('ApiDocsPage extended task documentation', () => {
|
||||
expect(html).toContain('兼容接口');
|
||||
expect(html).toContain('https://ai.51easyai.com/api/v1');
|
||||
expect(html).toContain('接口数量');
|
||||
expect(html).toContain('>54<');
|
||||
expect(html).toContain('>56<');
|
||||
expect(html).toContain('/api/v1/videos/generations');
|
||||
expect(html).toContain('/api/v1/resource/material');
|
||||
expect(html).toContain('不要求调用方显式传入');
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Popover as AntPopover } from 'antd';
|
||||
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, SlidersHorizontal, Trash2, UserRound } from 'lucide-react';
|
||||
import type { AdminGatewayTask, GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import type { AdminGatewayTask, GatewayAccessRuleBatchRequest, GatewayAPIKeyAccessRuleDiagnostic, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import type { ConsoleData } from '../app-state';
|
||||
import { EntityTable } from '../components/EntityTable';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui';
|
||||
import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||||
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
|
||||
import { listTaskParamPreprocessing } from '../api';
|
||||
import { listApiKeyAssignableModels, listTaskParamPreprocessing } from '../api';
|
||||
|
||||
const tabs = [
|
||||
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
||||
@@ -24,9 +24,12 @@ const apiKeyScopeOptions = [
|
||||
{ value: 'embedding', label: '向量', description: 'Embeddings' },
|
||||
{ value: 'rerank', label: '重排', description: 'Reranks' },
|
||||
{ value: 'image', label: '图像', description: 'Images' },
|
||||
{ value: 'image_vectorize', label: '图像向量化', description: 'Image Vectorize' },
|
||||
{ value: 'video', label: '视频', description: 'Videos' },
|
||||
{ value: 'video_enhance', label: '视频增强', description: 'Video Upscale / Enhance' },
|
||||
{ value: 'music', label: '音乐生成', description: 'Song / Music' },
|
||||
{ value: 'audio', label: '语音合成', description: 'Speech / TTS' },
|
||||
{ value: 'voice_clone', label: '声音克隆', description: 'Voice Clone' },
|
||||
{ value: 'all', label: '全部能力', description: '不按接口能力限制' },
|
||||
] as const;
|
||||
|
||||
@@ -36,7 +39,6 @@ export function WorkspacePage(props: {
|
||||
apiKeyForm: ApiKeyForm;
|
||||
apiKeySecret: string;
|
||||
apiKeySecretsById: Record<string, string>;
|
||||
apiKeyPolicyModels: PlatformModel[];
|
||||
data: ConsoleData;
|
||||
message: string;
|
||||
section: WorkspaceSection;
|
||||
@@ -389,10 +391,10 @@ function ApiKeyPanel(props: {
|
||||
apiKeyForm: ApiKeyForm;
|
||||
apiKeySecret: string;
|
||||
apiKeySecretsById: Record<string, string>;
|
||||
apiKeyPolicyModels: PlatformModel[];
|
||||
data: ConsoleData;
|
||||
message: string;
|
||||
state: LoadState;
|
||||
token: string;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||
@@ -407,6 +409,10 @@ function ApiKeyPanel(props: {
|
||||
const [scopeError, setScopeError] = useState('');
|
||||
const [pendingDelete, setPendingDelete] = useState<GatewayApiKey | null>(null);
|
||||
const [localMessage, setLocalMessage] = useState('');
|
||||
const [policyModels, setPolicyModels] = useState<PlatformModel[]>([]);
|
||||
const [policyDiagnostics, setPolicyDiagnostics] = useState<GatewayAPIKeyAccessRuleDiagnostic[]>([]);
|
||||
const [policyState, setPolicyState] = useState<LoadState>('idle');
|
||||
const [policyError, setPolicyError] = useState('');
|
||||
const selectedPolicyKey = useMemo(
|
||||
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
|
||||
[policyApiKeyId, props.data.apiKeys],
|
||||
@@ -415,7 +421,44 @@ function ApiKeyPanel(props: {
|
||||
() => props.data.apiKeys.find((item) => item.id === scopeApiKeyId),
|
||||
[scopeApiKeyId, props.data.apiKeys],
|
||||
);
|
||||
const permissionPlatforms = useMemo(() => platformsForPermissionTree(props.apiKeyPolicyModels), [props.apiKeyPolicyModels]);
|
||||
const permissionPlatforms = useMemo(() => platformsForPermissionTree(policyModels), [policyModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!policyApiKeyId) {
|
||||
setPolicyModels([]);
|
||||
setPolicyDiagnostics([]);
|
||||
setPolicyState('idle');
|
||||
setPolicyError('');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPolicyState('loading');
|
||||
setPolicyError('');
|
||||
void listApiKeyAssignableModels(props.token, policyApiKeyId).then((response) => {
|
||||
if (cancelled) return;
|
||||
setPolicyModels(response.items);
|
||||
setPolicyDiagnostics(response.ruleDiagnostics);
|
||||
setPolicyState('ready');
|
||||
}).catch((error) => {
|
||||
if (cancelled) return;
|
||||
setPolicyModels([]);
|
||||
setPolicyDiagnostics([]);
|
||||
setPolicyState('error');
|
||||
setPolicyError(error instanceof Error ? error.message : 'API Key 可分配模型加载失败');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [policyApiKeyId, props.token]);
|
||||
|
||||
async function savePolicyRules(input: GatewayAccessRuleBatchRequest) {
|
||||
await props.onBatchAccessRules(input);
|
||||
if (!policyApiKeyId) return;
|
||||
const response = await listApiKeyAssignableModels(props.token, policyApiKeyId);
|
||||
setPolicyModels(response.items);
|
||||
setPolicyDiagnostics(response.ruleDiagnostics);
|
||||
setPolicyState('ready');
|
||||
}
|
||||
|
||||
async function copyApiKey(item: GatewayApiKey) {
|
||||
const secret = apiKeySecretFor(item, props.apiKeySecretsById);
|
||||
@@ -620,15 +663,17 @@ function ApiKeyPanel(props: {
|
||||
onClose={() => setPolicyApiKeyId('')}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
>
|
||||
{policyError && <p className="formMessage error">{policyError}</p>}
|
||||
<AccessPermissionEditor
|
||||
accessRules={props.data.accessRules}
|
||||
metadataMode="api_key_permission_tree"
|
||||
platformModels={props.apiKeyPolicyModels}
|
||||
platformModels={policyModels}
|
||||
platforms={permissionPlatforms}
|
||||
state={props.state}
|
||||
ruleDiagnostics={policyDiagnostics}
|
||||
state={policyState}
|
||||
subjectId={selectedPolicyKey?.id ?? ''}
|
||||
subjectType="api_key"
|
||||
onBatchAccessRules={props.onBatchAccessRules}
|
||||
onBatchAccessRules={savePolicyRules}
|
||||
/>
|
||||
</FormDialog>
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import { AccessPermissionEditor } from './AccessPermissionEditor';
|
||||
|
||||
describe('AccessPermissionEditor API Key diagnostics', () => {
|
||||
it('shows retained ineffective rules and only the scope-pruned model capabilities', () => {
|
||||
const platform = {
|
||||
id: 'platform-1',
|
||||
provider: 'openai',
|
||||
platformKey: 'scope-test',
|
||||
name: 'Scope Test',
|
||||
authType: 'bearer',
|
||||
status: 'enabled',
|
||||
priority: 0,
|
||||
effectivePriority: 0,
|
||||
defaultPricingMode: 'inherit',
|
||||
defaultDiscountFactor: 1,
|
||||
createdAt: '2026-08-03T00:00:00Z',
|
||||
updatedAt: '2026-08-03T00:00:00Z',
|
||||
} satisfies IntegrationPlatform;
|
||||
const model = {
|
||||
id: 'model-1',
|
||||
platformId: platform.id,
|
||||
modelName: 'multi-model',
|
||||
modelType: ['image_generate'],
|
||||
displayName: 'Multi Model',
|
||||
pricingMode: 'inherit',
|
||||
rateLimitPolicyMode: 'inherit',
|
||||
enabled: true,
|
||||
createdAt: '2026-08-03T00:00:00Z',
|
||||
updatedAt: '2026-08-03T00:00:00Z',
|
||||
} satisfies PlatformModel;
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AccessPermissionEditor
|
||||
accessRules={[]}
|
||||
platformModels={[model]}
|
||||
platforms={[platform]}
|
||||
ruleDiagnostics={[{
|
||||
ruleId: 'rule-1',
|
||||
resourceType: 'platform_model',
|
||||
resourceId: 'text-model',
|
||||
resourceName: 'Text Model',
|
||||
effect: 'allow',
|
||||
effective: false,
|
||||
reason: 'scope_not_allowed',
|
||||
}]}
|
||||
state="ready"
|
||||
subjectId="key-1"
|
||||
subjectType="api_key"
|
||||
onBatchAccessRules={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('1 条规则当前不生效');
|
||||
expect(html).toContain('Text Model');
|
||||
expect(html).toContain('不在当前 API Key 的能力范围内');
|
||||
expect(html).toContain('移除规则');
|
||||
expect(html).toContain('image_generate');
|
||||
expect(html).not.toContain('text_generate');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type {
|
||||
GatewayAccessEffect,
|
||||
GatewayAPIKeyAccessRuleDiagnostic,
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleBatchRequest,
|
||||
GatewayAccessRuleResourceRequest,
|
||||
@@ -14,7 +15,7 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, Inpu
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type Effect = Extract<GatewayAccessEffect, 'allow' | 'deny'>;
|
||||
type ResourceType = Extract<GatewayAccessResourceType, 'platform' | 'platform_model'>;
|
||||
type ResourceType = GatewayAccessResourceType;
|
||||
type ResourceKey = `${ResourceType}:${string}`;
|
||||
|
||||
type PlatformNode = {
|
||||
@@ -34,6 +35,7 @@ export function AccessPermissionEditor(props: {
|
||||
metadataMode?: string;
|
||||
platformModels: PlatformModel[];
|
||||
platforms: IntegrationPlatform[];
|
||||
ruleDiagnostics?: GatewayAPIKeyAccessRuleDiagnostic[];
|
||||
state: LoadState;
|
||||
subjectId: string;
|
||||
subjectType: Extract<GatewayAccessSubjectType, 'user_group' | 'api_key'>;
|
||||
@@ -51,6 +53,10 @@ export function AccessPermissionEditor(props: {
|
||||
[props.accessRules, props.subjectId, props.subjectType],
|
||||
);
|
||||
const ruleByEffectAndResource = useMemo(() => buildRuleIndex(subjectRules), [subjectRules]);
|
||||
const ineffectiveRules = useMemo(
|
||||
() => (props.ruleDiagnostics ?? []).filter((diagnostic) => !diagnostic.effective),
|
||||
[props.ruleDiagnostics],
|
||||
);
|
||||
const allowTree = useMemo(() => filterTree(platformTree, allowSearch), [allowSearch, platformTree]);
|
||||
const denyTree = useMemo(() => filterTree(platformTree, denySearch), [denySearch, platformTree]);
|
||||
|
||||
@@ -65,19 +71,20 @@ export function AccessPermissionEditor(props: {
|
||||
}
|
||||
|
||||
async function setPlatformPermission(effect: Effect, platform: PlatformNode, enabled: boolean) {
|
||||
const keys = [
|
||||
makeResourceKey('platform', platform.id),
|
||||
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
|
||||
];
|
||||
await batchApply(effect, keys, enabled);
|
||||
const modelKeys = platform.models.map((model) => makeResourceKey('platform_model', model.id));
|
||||
if (enabled && props.subjectType === 'api_key') {
|
||||
await batchApply(effect, modelKeys, true);
|
||||
return;
|
||||
}
|
||||
await batchApply(effect, [makeResourceKey('platform', platform.id), ...modelKeys], enabled);
|
||||
}
|
||||
|
||||
async function selectAll(effect: Effect, nodes: PlatformNode[]) {
|
||||
await batchApply(effect, visibleResourceKeys(nodes), true);
|
||||
await batchApply(effect, visibleResourceKeys(nodes, props.subjectType !== 'api_key'), true);
|
||||
}
|
||||
|
||||
async function reverseVisible(effect: Effect, nodes: PlatformNode[]) {
|
||||
const keys = visibleResourceKeys(nodes);
|
||||
const keys = visibleResourceKeys(nodes, props.subjectType !== 'api_key');
|
||||
const upsertKeys: ResourceKey[] = [];
|
||||
const deleteKeys: ResourceKey[] = [];
|
||||
for (const key of keys) {
|
||||
@@ -88,6 +95,16 @@ export function AccessPermissionEditor(props: {
|
||||
await applyPermissionBatch(effect, upsertKeys, deleteKeys);
|
||||
}
|
||||
|
||||
async function removeIneffectiveRule(diagnostic: GatewayAPIKeyAccessRuleDiagnostic) {
|
||||
if (diagnostic.effect !== 'allow' && diagnostic.effect !== 'deny') return;
|
||||
await applyPermissionBatch(
|
||||
diagnostic.effect,
|
||||
[],
|
||||
[makeResourceKey(diagnostic.resourceType as ResourceType, diagnostic.resourceId)],
|
||||
'失效规则移除失败',
|
||||
);
|
||||
}
|
||||
|
||||
async function clearEffect(effect: Effect) {
|
||||
const keys = subjectRules
|
||||
.filter((rule) => rule.effect === effect)
|
||||
@@ -138,11 +155,32 @@ export function AccessPermissionEditor(props: {
|
||||
return (
|
||||
<div className="accessEditorStack">
|
||||
{localError && <p className="formMessage">{localError}</p>}
|
||||
{ineffectiveRules.length > 0 && (
|
||||
<section className="accessRuleDiagnostics" aria-label="失效 API Key 权限规则">
|
||||
<div>
|
||||
<strong>{ineffectiveRules.length} 条规则当前不生效</strong>
|
||||
<span>规则已保留,不会扩大权限或阻断其他 API Key。</span>
|
||||
</div>
|
||||
{ineffectiveRules.map((diagnostic) => (
|
||||
<div className="accessRuleDiagnosticRow" key={diagnostic.ruleId}>
|
||||
<span>
|
||||
<Badge variant="outline">{diagnostic.effect === 'allow' ? '专属' : '排除'}</Badge>
|
||||
<strong>{diagnostic.resourceName || diagnostic.resourceId}</strong>
|
||||
<small>{accessRuleDiagnosticReason(diagnostic.reason)}</small>
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={() => void removeIneffectiveRule(diagnostic)}>
|
||||
移除规则
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<section className="accessPermissionGrid">
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="allow"
|
||||
expanded={allowExpanded}
|
||||
modelOnlySelection={props.subjectType === 'api_key'}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={allowSearch}
|
||||
state={props.state}
|
||||
@@ -163,6 +201,7 @@ export function AccessPermissionEditor(props: {
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="deny"
|
||||
expanded={denyExpanded}
|
||||
modelOnlySelection={props.subjectType === 'api_key'}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={denySearch}
|
||||
state={props.state}
|
||||
@@ -188,6 +227,7 @@ function PermissionTreePanel(props: {
|
||||
effect: Effect;
|
||||
emptyText: string;
|
||||
expanded: Set<string>;
|
||||
modelOnlySelection: boolean;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
search: string;
|
||||
state: LoadState;
|
||||
@@ -229,6 +269,7 @@ function PermissionTreePanel(props: {
|
||||
key={platform.id}
|
||||
platform={platform}
|
||||
rules={props.rules}
|
||||
modelOnlySelection={props.modelOnlySelection}
|
||||
onToggleExpanded={props.onToggleExpanded}
|
||||
onTogglePlatformPermission={props.onTogglePlatformPermission}
|
||||
onTogglePermission={props.onTogglePermission}
|
||||
@@ -245,6 +286,7 @@ function PlatformPermissionNode(props: {
|
||||
expanded: boolean;
|
||||
platform: PlatformNode;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
modelOnlySelection: boolean;
|
||||
onToggleExpanded: (platformId: string) => void;
|
||||
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
|
||||
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void;
|
||||
@@ -252,7 +294,17 @@ function PlatformPermissionNode(props: {
|
||||
const platformRuleKey = `${props.effect}:${makeResourceKey('platform', props.platform.id)}`;
|
||||
const checkedModels = props.platform.models.filter((model) => props.rules.has(`${props.effect}:${makeResourceKey('platform_model', model.id)}`)).length;
|
||||
const platformChecked = props.rules.has(platformRuleKey);
|
||||
const platformState = platformChecked ? true : checkedModels > 0 ? 'indeterminate' : false;
|
||||
const platformState = props.modelOnlySelection
|
||||
? checkedModels > 0 && checkedModels === props.platform.models.length
|
||||
? true
|
||||
: checkedModels > 0 || platformChecked
|
||||
? 'indeterminate'
|
||||
: false
|
||||
: platformChecked
|
||||
? true
|
||||
: checkedModels > 0
|
||||
? 'indeterminate'
|
||||
: false;
|
||||
return (
|
||||
<div className="accessTreeNode">
|
||||
<div className="accessTreeRow">
|
||||
@@ -352,9 +404,9 @@ function countEffectRules(rules: GatewayAccessRule[], effect: Effect) {
|
||||
}, { platforms: 0, models: 0 });
|
||||
}
|
||||
|
||||
function visibleResourceKeys(nodes: PlatformNode[]): ResourceKey[] {
|
||||
function visibleResourceKeys(nodes: PlatformNode[], includePlatforms: boolean): ResourceKey[] {
|
||||
return nodes.flatMap((platform) => [
|
||||
makeResourceKey('platform', platform.id),
|
||||
...(includePlatforms ? [makeResourceKey('platform', platform.id)] : []),
|
||||
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
|
||||
]);
|
||||
}
|
||||
@@ -382,10 +434,23 @@ function resourceRequestFromKey(resourceKey: ResourceKey): GatewayAccessRuleReso
|
||||
}
|
||||
|
||||
function resourceKeyFromRule(rule: GatewayAccessRule): ResourceKey | undefined {
|
||||
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') return undefined;
|
||||
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model' && rule.resourceType !== 'base_model') return undefined;
|
||||
return makeResourceKey(rule.resourceType, rule.resourceId);
|
||||
}
|
||||
|
||||
function accessRuleDiagnosticReason(reason?: string) {
|
||||
switch (reason) {
|
||||
case 'resource_unavailable':
|
||||
return '平台或模型当前未启用';
|
||||
case 'owner_access_revoked':
|
||||
return '已被用户组、租户或用户权限收回';
|
||||
case 'scope_not_allowed':
|
||||
return '不在当前 API Key 的能力范围内';
|
||||
default:
|
||||
return '当前规则不生效';
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeResourceKeys(keys: ResourceKey[]) {
|
||||
return Array.from(new Set(keys.filter(Boolean)));
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const publicApiCatalogGroups: PublicApiCatalogGroup[] = [
|
||||
{ method: 'POST', path: '/api/v1/api-keys', description: '创建 API Key' },
|
||||
{ method: 'GET', path: '/api/v1/api-keys/access-rules', description: '查询 Key 访问规则' },
|
||||
{ method: 'POST', path: '/api/v1/api-keys/access-rules/batch', description: '批量设置 Key 访问规则' },
|
||||
{ method: 'GET', path: '/api/v1/api-keys/assignable-models', description: '查询可分配模型' },
|
||||
{ method: 'GET', path: '/api/v1/api-keys/{apiKeyID}/assignable-models', description: '查询指定 Key 可分配模型与失效规则' },
|
||||
{ method: 'PATCH', path: '/api/v1/api-keys/{apiKeyID}/scopes', description: '更新 Key 权限范围' },
|
||||
{ method: 'PATCH', path: '/api/v1/api-keys/{apiKeyID}/disable', description: '禁用 Key' },
|
||||
{ method: 'DELETE', path: '/api/v1/api-keys/{apiKeyID}', description: '删除 Key' },
|
||||
@@ -77,7 +77,9 @@ export const publicApiCatalogGroups: PublicApiCatalogGroup[] = [
|
||||
endpoints: [
|
||||
{ method: 'GET', path: '/api/v1/model-catalog', description: '模型能力目录' },
|
||||
{ method: 'GET', path: '/api/v1/platforms', description: '当前用户可用平台' },
|
||||
{ method: 'GET', path: '/api/v1/models', description: '当前用户可用模型' },
|
||||
{ method: 'GET', path: '/api/v1/platform-models', description: '当前身份可用的平台模型来源明细' },
|
||||
{ method: 'GET', path: '/v1/models', description: 'OpenAI 兼容逻辑模型列表' },
|
||||
{ method: 'GET', path: '/api/v1/models', description: '已弃用的平台模型来源明细' },
|
||||
{ method: 'GET', path: '/api/v1/playground/models', description: 'Playground 可用模型' },
|
||||
{ method: 'POST', path: '/api/v1/pricing/estimate', description: '请求价格预估' },
|
||||
],
|
||||
|
||||
@@ -467,6 +467,48 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.accessRuleDiagnostics {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--warning-border, var(--border-subtle));
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.accessRuleDiagnostics > div:first-child,
|
||||
.accessRuleDiagnosticRow,
|
||||
.accessRuleDiagnosticRow > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.accessRuleDiagnostics > div:first-child {
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.accessRuleDiagnostics > div:first-child strong {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.accessRuleDiagnosticRow {
|
||||
justify-content: space-between;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.accessRuleDiagnosticRow > span {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.accessRuleDiagnosticRow small {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.accessPermissionPanel .shCardContent {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
Reference in New Issue
Block a user