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 [];