feat(ssf): 实现安全事件一键连接与凭据托管

新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。

增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。
This commit is contained in:
2026-07-15 17:25:00 +08:00
parent f30aaeb2d4
commit 9efeb16fd1
31 changed files with 2738 additions and 227 deletions
+58 -2
View File
@@ -32,6 +32,7 @@ import type {
PricingRuleSet,
RateLimitWindow,
RuntimePolicySet,
SecurityEventConnectionResponse,
UserGroupUpsertRequest,
UserGroup,
WalletRechargeRequest,
@@ -46,6 +47,7 @@ import {
createPlatform,
createTenant,
createUserGroup,
connectSecurityEventTransmitter,
deleteAccessRule,
deleteApiKey,
deleteFileStorageChannel,
@@ -53,6 +55,7 @@ import {
deletePlatform,
deleteTenant,
deleteUserGroup,
disconnectSecurityEventConnection,
GatewayApiError,
getClientCustomizationSettings,
getHealth,
@@ -61,6 +64,7 @@ import {
getFileStorageSettings,
getNetworkProxyConfig,
getRunnerPolicy,
getSecurityEventConnection,
getWalletSummary,
listAccessRules,
listAuditLogs,
@@ -93,6 +97,7 @@ import {
rechargeUserWalletBalance,
replacePlatformModels,
restoreModelRuntimeStatus,
rotateSecurityEventConnectionCredential,
type HealthResponse,
updateAccessRule,
updateApiKeyScopes,
@@ -104,6 +109,7 @@ import {
updatePlatformDynamicPriority,
updateTenant,
updateUserGroup,
verifySecurityEventConnection,
} from './api';
import type { ConsoleData, StatItem } from './app-state';
import { AppShell } from './components/layout/AppShell';
@@ -173,6 +179,7 @@ type DataKey =
| 'clientCustomizationSettings'
| 'fileStorageChannels'
| 'fileStorageSettings'
| 'securityEventConnection'
| 'platforms'
| 'models'
| 'providers'
@@ -222,6 +229,7 @@ export function App() {
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
const [fileStorageSettings, setFileStorageSettings] = useState<FileStorageSettings | null>(null);
const [securityEventConnection, setSecurityEventConnection] = useState<SecurityEventConnectionResponse | null>(null);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
@@ -383,6 +391,7 @@ export function App() {
modelRateLimits,
modelRateLimitsUpdatedAt,
runtimePolicySets,
securityEventConnection,
taskResult,
tasks,
tenants,
@@ -390,7 +399,7 @@ export function App() {
users,
walletAccounts,
walletTransactions,
}), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
}), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@@ -480,6 +489,9 @@ export function App() {
case 'fileStorageSettings':
setFileStorageSettings(await getFileStorageSettings(nextToken));
return;
case 'securityEventConnection':
setSecurityEventConnection(await getSecurityEventConnection(nextToken));
return;
case 'playgroundModels':
setPlaygroundModels((await listPlayableModels(nextToken)).items);
return;
@@ -1017,6 +1029,44 @@ export function App() {
}
}
async function connectSecurityEvents(transmitterIssuer: string) {
setCoreState('loading');
setCoreMessage('');
try {
const connection = await connectSecurityEventTransmitter(token, transmitterIssuer);
setSecurityEventConnection(connection);
setCoreState('ready');
setCoreMessage('认证中心安全事件连接正在验证。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '连接认证中心失败');
throw err;
}
}
async function refreshSecurityEvents() {
const connection = await getSecurityEventConnection(token);
setSecurityEventConnection(connection);
}
async function verifySecurityEvents() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await verifySecurityEventConnection(token, connection.version));
}
async function rotateSecurityEventsCredential() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await rotateSecurityEventConnectionCredential(token, connection.version));
}
async function disconnectSecurityEvents() {
const connection = securityEventConnection?.connection;
if (!connection) return;
setSecurityEventConnection(await disconnectSecurityEventConnection(token, connection.version));
}
async function removeFileStorageChannel(channelId: string) {
setCoreState('loading');
setCoreMessage('');
@@ -1095,6 +1145,7 @@ export function App() {
setPlaygroundModels([]);
setNetworkProxyConfig(null);
setFileStorageChannels([]);
setSecurityEventConnection(null);
setProviders([]);
setBaseModels([]);
setPricingRules([]);
@@ -1332,6 +1383,11 @@ export function App() {
onSaveFileStorageChannel={saveFileStorageChannel}
onSaveFileStorageSettings={saveFileStorageSettings}
onSaveClientCustomizationSettings={saveClientCustomizationSettings}
onConnectSecurityEvents={connectSecurityEvents}
onDisconnectSecurityEvents={disconnectSecurityEvents}
onRefreshSecurityEvents={refreshSecurityEvents}
onRotateSecurityEventsCredential={rotateSecurityEventsCredential}
onVerifySecurityEvents={verifySecurityEvents}
onSaveTenant={saveTenant}
onSaveUser={saveUser}
onRechargeUserWalletBalance={rechargeUserWallet}
@@ -1553,7 +1609,7 @@ function dataKeysForRoute(
case 'accessRules':
return ['accessRules', 'userGroups', 'platforms', 'models'];
case 'systemSettings':
return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels'];
return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels', 'securityEventConnection'];
default:
return [];
}
+24
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
connectSecurityEventTransmitter,
deleteOIDCBrowserSession,
GatewayApiError,
gatewayErrorMessage,
@@ -34,6 +35,29 @@ describe('Gateway provisioning errors', () => {
});
});
describe('security event connection transport', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('sends only the public transmitter issuer and concurrency headers', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('PUT');
expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' });
expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i);
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-');
});
});
describe('OIDC browser session transport', () => {
afterEach(() => {
vi.unstubAllGlobals();
+35
View File
@@ -43,6 +43,7 @@ import type {
RateLimitWindow,
RuntimePolicySet,
RuntimePolicySetUpsertRequest,
SecurityEventConnectionResponse,
UserGroup,
UserGroupUpsertRequest,
WalletAdjustmentResponse,
@@ -1000,6 +1001,40 @@ export async function updateClientCustomizationSettings(
});
}
const securityEventConnectionPath = '/api/admin/system/identity/security-events/connection';
export async function getSecurityEventConnection(token: string): Promise<SecurityEventConnectionResponse> {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, { token });
}
export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string): Promise<SecurityEventConnectionResponse> {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath, {
body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token,
headers: { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` },
});
}
export async function verifySecurityEventConnection(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '/verify', version, 'POST', 'verify');
}
export async function rotateSecurityEventConnectionCredential(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '/rotate-credential', version, 'POST', 'rotate');
}
export async function disconnectSecurityEventConnection(token: string, version: number): Promise<SecurityEventConnectionResponse> {
return securityEventConnectionWrite(token, '', version, 'DELETE', 'disconnect');
}
function securityEventConnectionWrite(token: string, suffix: string, version: number, method: string, action: string) {
return request<SecurityEventConnectionResponse>(securityEventConnectionPath + suffix, {
method, token, headers: {
'Idempotency-Key': `ssf-${action}-${crypto.randomUUID()}`,
'If-Match': `W/"${version}"`,
},
});
}
export async function getPublicClientCustomization(): Promise<ClientCustomizationSettings> {
return request<ClientCustomizationSettings>('/api/v1/public/client-customization', { auth: false });
}
+2
View File
@@ -23,6 +23,7 @@ import type {
PricingRuleSet,
RateLimitWindow,
RuntimePolicySet,
SecurityEventConnectionResponse,
UserGroup,
} from '@easyai-ai-gateway/contracts';
@@ -48,6 +49,7 @@ export interface ConsoleData {
modelRateLimits: ModelRateLimitStatus[];
modelRateLimitsUpdatedAt: number | null;
runtimePolicySets: RuntimePolicySet[];
securityEventConnection: SecurityEventConnectionResponse | null;
taskResult: GatewayTask | null;
tasks: GatewayTask[];
tenants: GatewayTenant[];
+11
View File
@@ -82,6 +82,11 @@ export function AdminPage(props: {
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
onVerifySecurityEvents: () => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
@@ -189,12 +194,18 @@ export function AdminPage(props: {
channels={props.data.fileStorageChannels}
clientCustomizationSettings={props.data.clientCustomizationSettings}
settings={props.data.fileStorageSettings}
securityEventConnection={props.data.securityEventConnection}
message={props.operationMessage}
state={props.state}
onDeleteFileStorageChannel={props.onDeleteFileStorageChannel}
onSaveFileStorageChannel={props.onSaveFileStorageChannel}
onSaveFileStorageSettings={props.onSaveFileStorageSettings}
onSaveClientCustomizationSettings={props.onSaveClientCustomizationSettings}
onConnectSecurityEvents={props.onConnectSecurityEvents}
onDisconnectSecurityEvents={props.onDisconnectSecurityEvents}
onRefreshSecurityEvents={props.onRefreshSecurityEvents}
onRotateSecurityEventsCredential={props.onRotateSecurityEventsCredential}
onVerifySecurityEvents={props.onVerifySecurityEvents}
/>
)}
</div>
@@ -1,10 +1,10 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, Trash2 } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents';
type ClientCustomizationForm = {
clientEnglishName: string;
@@ -58,11 +58,17 @@ export function SystemSettingsPanel(props: {
clientCustomizationSettings: ClientCustomizationSettings | null;
message: string;
settings: FileStorageSettings | null;
securityEventConnection: SecurityEventConnectionResponse | null;
state: LoadState;
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
onVerifySecurityEvents: () => Promise<void>;
}) {
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
const [dialogOpen, setDialogOpen] = useState(false);
@@ -72,6 +78,8 @@ export function SystemSettingsPanel(props: {
const [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
const [localError, setLocalError] = useState('');
const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf');
const [disconnectOpen, setDisconnectOpen] = useState(false);
useEffect(() => {
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
@@ -81,6 +89,13 @@ export function SystemSettingsPanel(props: {
setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings));
}, [props.clientCustomizationSettings]);
useEffect(() => {
const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus;
if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined;
const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000);
return () => window.clearInterval(timer);
}, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]);
function openCreateDialog() {
setEditingChannel(null);
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
@@ -162,6 +177,7 @@ export function SystemSettingsPanel(props: {
tabs={[
{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> },
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
{ value: 'securityEvents', label: '认证中心安全事件', icon: <ShieldCheck size={15} /> },
]}
onValueChange={setActiveTab}
/>
@@ -275,6 +291,20 @@ export function SystemSettingsPanel(props: {
</section>
)}
{activeTab === 'securityEvents' && (
<SecurityEventConnectionPanel
connection={props.securityEventConnection}
issuer={transmitterIssuer}
loading={props.state === 'loading'}
onIssuerChange={setTransmitterIssuer}
onConnect={props.onConnectSecurityEvents}
onDisconnect={() => setDisconnectOpen(true)}
onRefresh={props.onRefreshSecurityEvents}
onRotate={props.onRotateSecurityEventsCredential}
onVerify={props.onVerifySecurityEvents}
/>
)}
<FormDialog
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
bodyClassName="fileStorageDialogBody"
@@ -363,10 +393,106 @@ export function SystemSettingsPanel(props: {
onCancel={() => setPendingDeleteChannel(null)}
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
/>
<ConfirmDialog
confirmLabel="断开连接"
description="系统会先删除远端 Stream,并继续使用水位和 RFC 7662 至少 360 秒;不会让旧 Token 因断开而重新有效。"
loading={props.state === 'loading'}
open={disconnectOpen}
title="确认断开认证中心安全事件连接?"
onCancel={() => setDisconnectOpen(false)}
onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }}
/>
</div>
);
}
function SecurityEventConnectionPanel(props: {
connection: SecurityEventConnectionResponse | null;
issuer: string;
loading: boolean;
onIssuerChange(value: string): void;
onConnect(issuer: string): Promise<void>;
onDisconnect(): void;
onRefresh(): Promise<void>;
onRotate(): Promise<void>;
onVerify(): Promise<void>;
}) {
const connection = props.connection?.connection;
const prerequisites = props.connection?.prerequisites;
const missing = [
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
!prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '',
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
].filter(Boolean);
return (
<section className="fileStoragePanel">
<div className="fileStorageSettingsCard">
<div>
<strong>SSF / CAEP </strong>
<span>Gateway Push Bearer RFC 7662 Client</span>
</div>
<Badge variant={connection?.lifecycleStatus === 'enabled' ? 'success' : connection ? 'secondary' : 'outline'}>
{securityEventLifecycleLabel(connection?.lifecycleStatus)}
</Badge>
<Button type="button" variant="outline" size="sm" onClick={() => void props.onRefresh()} disabled={props.loading}>
<RefreshCw size={14} />
</Button>
</div>
{!connection && (
<Card>
<CardContent className="pageStack">
<div>
<strong></strong>
<p className="mutedText"> Application Gateway </p>
</div>
{missing.length > 0 && <div className="formMessage">{missing.join('')}</div>}
<div className="fileStorageMeta">
<span> Client: {prerequisites?.managementClientId || '未配置'}</span>
</div>
<Label>
SSF Transmitter Issuer
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
<small> IssuerEndpointScopePush Bearer Stream </small>
</Label>
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim()} onClick={() => void props.onConnect(props.issuer.trim())}>
<Link2 size={15} />
</Button>
</CardContent>
</Card>
)}
{connection && (
<Card>
<CardContent className="pageStack">
<div className="fileStorageMeta">
<span> Client: {connection.managementClientId}</span>
<span>Receiver Endpoint: {connection.receiverEndpoint}</span>
<span>Issuer: {connection.transmitterIssuer}</span>
<span>Audience: {connection.audience ?? '正在获取'}</span>
<span>Stream ID: {connection.streamId ?? '正在创建'}</span>
<span>: {securityEventHealthLabel(connection.healthMode)}</span>
<span> Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'}</span>
{connection.lastErrorCategory && <span>: {connection.lastErrorCategory}</span>}
</div>
<div className="fileStorageToolbar">
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={() => void props.onVerify()}><RefreshCw size={14} /></Button>
<Button type="button" variant="outline" size="sm" disabled={props.loading || connection.lifecycleStatus === 'rotating'} onClick={() => void props.onRotate()}><RotateCcw size={14} /></Button>
<Button type="button" variant="destructive" size="sm" disabled={props.loading || connection.lifecycleStatus === 'retiring'} onClick={props.onDisconnect}><Unplug size={14} /></Button>
</div>
</CardContent>
</Card>
)}
</section>
);
}
function securityEventLifecycleLabel(status?: string) {
return ({ connecting: '连接中', verifying: '验证中', bootstrap: '内省保护期', enabled: '推送健康', degraded: '已降级', rotating: '轮换中', disconnect_pending: '等待断开', retiring: '安全退役中', error: '连接异常' } as Record<string, string>)[status ?? ''] ?? '未连接';
}
function securityEventHealthLabel(mode: string) {
return ({ disabled: '未启用', bootstrap: 'RFC 7662 启动保护', push_healthy: 'Push 健康', introspection_fallback: 'RFC 7662 降级' } as Record<string, string>)[mode] ?? mode;
}
function defaultClientCustomizationForm(): ClientCustomizationForm {
return {
clientEnglishName: 'EasyAI AI Gateway',