refactor(access): 统一分层白名单权限语义
取消跨主体专属占用,按租户、用户组、用户、当前 API Key 和 scope 分层求交,并在任务落库前统一校验候选。\n\n增加旧 allow 规则归档清理迁移、脱敏审计工具和回滚运行手册,补齐主体隔离、deny 优先及列表与运行时一致性测试。
This commit is contained in:
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user