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:
2026-08-03 09:17:15 +08:00
parent c28bf74230
commit cc97e6649c
26 changed files with 2249 additions and 220 deletions
+15 -22
View File
@@ -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 [];
+4 -4
View File
@@ -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
View File
@@ -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> {
+4 -4
View File
@@ -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('不要求调用方显式传入');
+53 -8
View File
@@ -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)));
}
+4 -2
View File
@@ -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: '请求价格预估' },
],
+42
View File
@@ -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;