fix(identity): 兼容多租户 V2 无固定租户映射

多租户 Revision 改为按 Token tid 动态解析租户,配对和验证不再要求 default 映射;单租户 V1 继续保留固定映射门禁。同步补齐 tenantMode 共享契约与 OpenAPI 产物。\n\n验证:Web 测试 129 项通过;pnpm lint 通过;Web 生产构建通过;pnpm openapi 通过。
This commit is contained in:
2026-07-28 18:02:59 +08:00
parent 5c679ff13f
commit 206e367dbf
5 changed files with 63 additions and 8 deletions
+15
View File
@@ -9753,6 +9753,9 @@
"tenantKey": {
"type": "string"
},
"tenantName": {
"type": "string"
},
"userGroupId": {
"type": "string"
},
@@ -12376,9 +12379,21 @@
"supersededAt": {
"type": "string"
},
"tenantContextAudience": {
"type": "string"
},
"tenantContextEndpoint": {
"type": "string"
},
"tenantContextScope": {
"type": "string"
},
"tenantId": {
"type": "string"
},
"tenantMode": {
"type": "string"
},
"tokenIntrospection": {
"type": "boolean"
},
+10
View File
@@ -32,6 +32,8 @@ definitions:
type: string
tenantKey:
type: string
tenantName:
type: string
userGroupId:
type: string
userGroupKey:
@@ -1858,8 +1860,16 @@ definitions:
$ref: '#/definitions/identity.RevisionState'
supersededAt:
type: string
tenantContextAudience:
type: string
tenantContextEndpoint:
type: string
tenantContextScope:
type: string
tenantId:
type: string
tenantMode:
type: string
tokenIntrospection:
type: boolean
updatedAt:
@@ -9,6 +9,7 @@ import {
executeIdentityOperation,
isStaleIdentityOperation,
pairingFailureMessage,
requiresLocalTenantMapping,
} from './UnifiedIdentityPanel';
describe('UnifiedIdentityPanel', () => {
@@ -20,12 +21,26 @@ describe('UnifiedIdentityPanel', () => {
expect(html).toContain('Gateway API 公网地址');
expect(html).not.toContain('value="/gateway-api"');
expect(html).toContain('Gateway Web 地址');
expect(html).toContain('Gateway 本地租户映射');
expect(html).toContain('Gateway 本地租户映射(仅单租户 V1');
expect(html).toContain('多租户 V2 无需填写');
const localTenantInput = html.match(/<input[^>]+placeholder="default"[^>]*>/)?.[0] ?? '';
expect(localTenantInput).not.toContain('required');
expect(localTenantInput).not.toContain('value="default"');
expect(html).not.toContain('Machine Client Secret');
expect(html).not.toContain('SSF Transmitter Issuer');
});
});
describe('local tenant mapping compatibility', () => {
it('does not require a fixed mapping for multi-tenant V2 revisions', () => {
expect(requiresLocalTenantMapping('multi_tenant')).toBe(false);
});
it('keeps the fixed mapping requirement for single-tenant V1 revisions', () => {
expect(requiresLocalTenantMapping('single_tenant')).toBe(true);
});
});
describe('PairingProgressCard', () => {
it('shows a safe actionable discovery failure and a recovery action', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} pairing={{
@@ -192,6 +207,7 @@ function identityRevision(
id: `${state}-revision`,
state,
schemaVersion: 1,
tenantMode: 'single_tenant',
authCenterUrl: 'https://auth.example.com',
issuer: 'https://issuer.example.com',
scopes: ['openid'],
@@ -24,7 +24,7 @@ export function UnifiedIdentityPanel(props: { token: string }) {
const [configuration, setConfiguration] = useState<IdentityConfigurationView | null>(null);
const [form, setForm] = useState<IdentityPairingInput>(defaultPairingInput);
const [showPairingForm, setShowPairingForm] = useState(false);
const [localTenantKey, setLocalTenantKey] = useState('default');
const [localTenantKey, setLocalTenantKey] = useState('');
const [legacyJwtEnabled, setLegacyJwtEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
@@ -120,6 +120,7 @@ export function UnifiedIdentityPanel(props: { token: string }) {
const previous = configuration?.previous;
const pairing = configuration?.pairing;
const runtime = configuration?.runtime;
const localTenantMappingRequired = requiresLocalTenantMapping(draft?.tenantMode);
const cleanupPending = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'pending';
const cleanupCompleted = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'completed';
const shouldShowPairing = !active && (showPairingForm || !pairing || cleanupCompleted);
@@ -188,7 +189,9 @@ export function UnifiedIdentityPanel(props: { token: string }) {
<RevisionDetails revision={draft} />
{draft.state === 'draft' && (
<div className="formGrid two">
<Label>Gateway <Input value={localTenantKey} onChange={(event) => setLocalTenantKey(event.target.value)} /></Label>
{localTenantMappingRequired
? <Label>Gateway <Input required value={localTenantKey} onChange={(event) => setLocalTenantKey(event.target.value)} placeholder="default" /></Label>
: <p className="mutedText"> V2 Token `tid` 使</p>}
<label className="identityCheckbox">
<input type="checkbox" checked={legacyJwtEnabled} onChange={(event) => setLegacyJwtEnabled(event.target.checked)} />
<span><strong> Legacy JWT</strong><small></small></span>
@@ -197,8 +200,8 @@ export function UnifiedIdentityPanel(props: { token: string }) {
)}
<div className="fileStorageToolbar">
{draft.state === 'draft' && <>
<Button type="button" variant="outline" disabled={loading || !localTenantKey.trim()} onClick={() => void saveDraftPolicy()}></Button>
<Button type="button" disabled={loading || !localTenantKey.trim()}
<Button type="button" variant="outline" disabled={loading || (localTenantMappingRequired && !localTenantKey.trim())} onClick={() => void saveDraftPolicy()}></Button>
<Button type="button" disabled={loading || (localTenantMappingRequired && !localTenantKey.trim())}
onClick={() => void run(() => validateIdentityRevision(props.token, draft.id, draft.version), '统一认证配置验证成功,可以激活。')}>
<ShieldCheck size={14} />
</Button>
@@ -241,7 +244,11 @@ export function UnifiedIdentityPanel(props: { token: string }) {
<Label><Input required type="password" autoComplete="one-time-code" value={form.onboardingCode} onChange={(event) => setForm({ ...form, onboardingCode: event.target.value })} placeholder="仅本次提交,不写入日志或数据库" /></Label>
<Label>Gateway API <Input required value={form.publicBaseUrl} onChange={(event) => setForm({ ...form, publicBaseUrl: event.target.value })} placeholder="https://api.gateway.example.com" /></Label>
<Label>Gateway Web <Input required value={form.webBaseUrl} onChange={(event) => setForm({ ...form, webBaseUrl: event.target.value })} placeholder="https://gateway.example.com" /></Label>
<Label>Gateway <Input required value={form.localTenantKey} onChange={(event) => setForm({ ...form, localTenantKey: event.target.value })} placeholder="default" /></Label>
<Label>
Gateway V1
<Input value={form.localTenantKey} onChange={(event) => setForm({ ...form, localTenantKey: event.target.value })} placeholder="default" />
<small> V2 Manifest Token `tid` </small>
</Label>
<label className="identityCheckbox">
<input type="checkbox" checked={form.legacyJwtEnabled} onChange={(event) => setForm({ ...form, legacyJwtEnabled: event.target.checked })} />
<span><strong> Legacy JWT</strong><small></small></span>
@@ -368,7 +375,9 @@ function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision
<span>Issuer: {revision.issuer || '等待 Manifest'}</span>
<span>Audience: {revision.audience || '等待 Manifest'}</span>
<span>Tenant: {revision.tenantId || '等待 Manifest'}</span>
<span>: {revision.localTenantKey}</span>
{revision.tenantMode === 'multi_tenant'
? <span>租户模式: 多租户 tid </span>
: <span>: {revision.localTenantKey}</span>}
<span> Client: {revision.browserClientId || '未启用'}</span>
<span> Client: {revision.machineClientId || '未启用'}</span>
<span>: {revision.capabilities.map(capabilityLabel).join('、') || '等待 Manifest'}</span>
@@ -392,11 +401,15 @@ function defaultPairingInput(): IdentityPairingInput {
onboardingCode: '',
publicBaseUrl: /^https?:\/\//i.test(configuredApiBaseUrl) ? configuredApiBaseUrl : '',
webBaseUrl,
localTenantKey: 'default',
localTenantKey: '',
legacyJwtEnabled: false,
};
}
export function requiresLocalTenantMapping(tenantMode?: string): boolean {
return tenantMode !== 'multi_tenant';
}
function pairingStatusLabel(status: string, cleanupStatus?: string) {
if (status === 'cancelled' && cleanupStatus === 'pending') return '正在销毁临时凭据并清理由本次 Revision 创建的安全事件连接';
return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record<string, string>)[status] ?? status;