feat(ssf): 托管机器凭据并支持动态恢复

Gateway 连接表单接收认证中心一次性交付的 machine Client 凭据,后端立即写入 SecretStore,数据库和公开响应只保留引用及公开 Client ID。SSF 管理 Token、Verification 和 RFC 7662 内省统一从动态凭据读取,支持现有连接无重启修复及进程重启恢复,环境变量仅保留为旧部署回退。\n\n验证:go test ./apps/api/...;pnpm nx test web;真实重启、OIDC 登录与 session-revoked 1.29 秒失效验收。
This commit is contained in:
2026-07-17 09:13:57 +08:00
parent 8e33d1d33e
commit ffb85b73af
15 changed files with 327 additions and 90 deletions
@@ -64,7 +64,7 @@ export function SystemSettingsPanel(props: {
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
@@ -411,7 +411,7 @@ function SecurityEventConnectionPanel(props: {
issuer: string;
loading: boolean;
onIssuerChange(value: string): void;
onConnect(issuer: string): Promise<void>;
onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise<void>;
onDisconnect(): void;
onRefresh(): Promise<void>;
onRotate(): Promise<void>;
@@ -419,9 +419,13 @@ function SecurityEventConnectionPanel(props: {
}) {
const connection = props.connection?.connection;
const prerequisites = props.connection?.prerequisites;
const [managementClientId, setManagementClientId] = useState('');
const [managementClientSecret, setManagementClientSecret] = useState('');
useEffect(() => {
if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId);
}, [managementClientId, prerequisites?.managementClientId]);
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 (
@@ -446,15 +450,22 @@ function SecurityEventConnectionPanel(props: {
<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>
<small>EndpointScopePush Bearer Stream </small>
</Label>
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim()} onClick={() => void props.onConnect(props.issuer.trim())}>
<Label>
Machine Client ID
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
</Label>
<Label>
Machine Client Secret
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
<small>Gateway SecretStore</small>
</Label>
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim() || !managementClientId.trim() || !managementClientSecret}
onClick={() => void props.onConnect(props.issuer.trim(), managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
<Link2 size={15} />
</Button>
</CardContent>
@@ -475,6 +486,21 @@ function SecurityEventConnectionPanel(props: {
{props.connection?.auditId && <span> Audit ID: {props.connection.auditId}</span>}
{connection.lastErrorCategory && <span>: {connection.lastErrorCategory}</span>}
</div>
{(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') && <div className="pageStack">
<div className="formMessage"> Stream</div>
<Label>
Machine Client ID
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
</Label>
<Label>
Machine Client Secret
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
</Label>
<Button type="button" disabled={props.loading || !managementClientId.trim() || !managementClientSecret}
onClick={() => void props.onConnect(connection.transmitterIssuer, managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
<Link2 size={15} />
</Button>
</div>}
<div className="fileStorageToolbar">
{connection.lifecycleStatus === 'error' && (
<Button type="button" size="sm" disabled={props.loading} onClick={() => void props.onConnect(connection.transmitterIssuer)}><Link2 size={14} /></Button>