refactor(access): 统一分层白名单权限语义

取消跨主体专属占用,按租户、用户组、用户、当前 API Key 和 scope 分层求交,并在任务落库前统一校验候选。\n\n增加旧 allow 规则归档清理迁移、脱敏审计工具和回滚运行手册,补齐主体隔离、deny 优先及列表与运行时一致性测试。
This commit is contained in:
2026-08-03 15:43:49 +08:00
parent c9393af43a
commit 7376d6fab6
20 changed files with 1490 additions and 513 deletions
@@ -1,7 +1,7 @@
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';
import { AccessPermissionEditor, ineffectiveRuleCleanupBatches } from './AccessPermissionEditor';
describe('AccessPermissionEditor API Key diagnostics', () => {
it('shows retained ineffective rules and only the scope-pruned model capabilities', () => {
@@ -54,10 +54,59 @@ describe('AccessPermissionEditor API Key diagnostics', () => {
);
expect(html).toContain('1 条规则当前不生效');
expect(html).toContain('未配置白名单');
expect(html).toContain('继承上级可用范围');
expect(html).toContain('允许使用(白名单)');
expect(html).toContain('清空允许');
expect(html).toContain('Text Model');
expect(html).toContain('不在当前 API Key 的能力范围内');
expect(html).toContain('移除规则');
expect(html).toContain('一键清理');
expect(html).toContain('image_generate');
expect(html).not.toContain('text_generate');
});
it('shows the restrictive whitelist state when the current subject has an allow', () => {
const html = renderToStaticMarkup(
<AccessPermissionEditor
accessRules={[{
id: 'rule-allow',
subjectType: 'api_key',
subjectId: 'key-1',
resourceType: 'platform_model',
resourceId: 'model-1',
effect: 'allow',
priority: 100,
minPermissionLevel: 0,
conditions: {},
metadata: {},
status: 'active',
createdAt: '2026-08-03T00:00:00Z',
updatedAt: '2026-08-03T00:00:00Z',
}]}
platformModels={[]}
platforms={[]}
state="ready"
subjectId="key-1"
subjectType="api_key"
onBatchAccessRules={vi.fn()}
/>,
);
expect(html).toContain('已配置白名单');
expect(html).toContain('仅允许所选资源');
expect(html).not.toContain('专属使用');
});
it('groups one-click cleanup by effect and deduplicates retained diagnostics', () => {
const batches = ineffectiveRuleCleanupBatches('api_key', 'key-1', [
{ ruleId: 'allow-1', resourceType: 'platform_model', resourceId: 'model-1', effect: 'allow', effective: false, reason: 'scope_not_allowed' },
{ ruleId: 'allow-duplicate', resourceType: 'platform_model', resourceId: 'model-1', effect: 'allow', effective: false, reason: 'scope_not_allowed' },
{ ruleId: 'deny-1', resourceType: 'platform', resourceId: 'platform-1', effect: 'deny', effective: false, reason: 'resource_unavailable' },
{ ruleId: 'effective', resourceType: 'platform_model', resourceId: 'model-2', effect: 'allow', effective: true },
]);
expect(batches).toHaveLength(2);
expect(batches[0]).toMatchObject({ effect: 'allow', deleteResources: [{ resourceType: 'platform_model', resourceId: 'model-1' }] });
expect(batches[1]).toMatchObject({ effect: 'deny', deleteResources: [{ resourceType: 'platform', resourceId: 'platform-1' }] });
});
});
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
import type {
GatewayAccessEffect,
GatewayAPIKeyAccessRuleDiagnostic,
@@ -46,6 +46,7 @@ export function AccessPermissionEditor(props: {
const [allowExpanded, setAllowExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
const [denyExpanded, setDenyExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
const [localError, setLocalError] = useState('');
const [cleaningIneffectiveRules, setCleaningIneffectiveRules] = useState(false);
const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]);
const subjectRules = useMemo(
@@ -105,6 +106,21 @@ export function AccessPermissionEditor(props: {
);
}
async function clearIneffectiveRules() {
if (!props.subjectId || ineffectiveRules.length === 0) return;
setLocalError('');
setCleaningIneffectiveRules(true);
try {
for (const batch of ineffectiveRuleCleanupBatches(props.subjectType, props.subjectId, ineffectiveRules)) {
await props.onBatchAccessRules(batch);
}
} catch (err) {
setLocalError(err instanceof Error ? err.message : '失效规则一键清理失败');
} finally {
setCleaningIneffectiveRules(false);
}
}
async function clearEffect(effect: Effect) {
const keys = subjectRules
.filter((rule) => rule.effect === effect)
@@ -143,6 +159,7 @@ export function AccessPermissionEditor(props: {
const allowSummary = countEffectRules(subjectRules, 'allow');
const denySummary = countEffectRules(subjectRules, 'deny');
const allowConfigured = allowSummary.platforms + allowSummary.models > 0;
if (!props.subjectId) {
return (
@@ -160,23 +177,38 @@ export function AccessPermissionEditor(props: {
<div>
<strong>{ineffectiveRules.length} </strong>
<span> API Key</span>
<Button
type="button"
className="accessRuleCleanupButton"
variant="outline"
size="sm"
disabled={props.state === 'loading' || cleaningIneffectiveRules}
onClick={() => void clearIneffectiveRules()}
>
<Trash2 size={14} />{cleaningIneffectiveRules ? '清理中' : '一键清理'}
</Button>
</div>
{ineffectiveRules.map((diagnostic) => (
<div className="accessRuleDiagnosticRow" key={diagnostic.ruleId}>
<span>
<Badge variant="outline">{diagnostic.effect === 'allow' ? '专属' : '排除'}</Badge>
<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 type="button" variant="outline" size="sm" disabled={props.state === 'loading' || cleaningIneffectiveRules} onClick={() => void removeIneffectiveRule(diagnostic)}>
</Button>
</div>
))}
</section>
)}
<div className="accessGroupHint accessWhitelistHint">
<strong>{allowConfigured ? '已配置白名单' : '未配置白名单'}</strong>
<span>{allowConfigured ? '仅允许所选资源;拒绝规则始终优先。' : '继承上级可用范围;拒绝规则仍会从中排除资源。'}</span>
</div>
<section className="accessPermissionGrid">
<PermissionTreePanel
clearLabel="清空允许"
emptyText="暂无可维护的平台模型"
effect="allow"
expanded={allowExpanded}
@@ -185,7 +217,7 @@ export function AccessPermissionEditor(props: {
search={allowSearch}
state={props.state}
summary={allowSummary}
title="专属使用(平台/模型"
title="允许使用(白名单"
tree={allowTree}
onClear={() => void clearEffect('allow')}
onExpandAll={() => setAllowExpanded(new Set(platformTree.map((item) => item.id)))}
@@ -198,6 +230,7 @@ export function AccessPermissionEditor(props: {
onTogglePermission={setPermission}
/>
<PermissionTreePanel
clearLabel="清空拒绝"
emptyText="暂无可维护的平台模型"
effect="deny"
expanded={denyExpanded}
@@ -223,7 +256,25 @@ export function AccessPermissionEditor(props: {
);
}
export function ineffectiveRuleCleanupBatches(
subjectType: Extract<GatewayAccessSubjectType, 'user_group' | 'api_key'>,
subjectId: string,
diagnostics: GatewayAPIKeyAccessRuleDiagnostic[],
): GatewayAccessRuleBatchRequest[] {
const batches: GatewayAccessRuleBatchRequest[] = [];
for (const effect of ['allow', 'deny'] as Effect[]) {
const keys = diagnostics
.filter((diagnostic) => !diagnostic.effective && diagnostic.effect === effect)
.map((diagnostic) => makeResourceKey(diagnostic.resourceType as ResourceType, diagnostic.resourceId));
const deleteResources = dedupeResourceKeys(keys).map(resourceRequestFromKey);
if (deleteResources.length === 0) continue;
batches.push({ subjectType, subjectId, effect, upsertResources: [], deleteResources });
}
return batches;
}
function PermissionTreePanel(props: {
clearLabel: string;
effect: Effect;
emptyText: string;
expanded: Set<string>;
@@ -259,7 +310,7 @@ function PermissionTreePanel(props: {
<Button type="button" variant="outline" size="sm" onClick={props.onFoldAll}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onSelectAll}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onReverse}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}>{props.clearLabel}</Button>
</div>
<div className="accessTreeBox">
{props.tree.length ? props.tree.map((platform) => (