fix(identity): 兼容多租户 V2 无固定租户映射
多租户 Revision 改为按 Token tid 动态解析租户,配对和验证不再要求 default 映射;单租户 V1 继续保留固定映射门禁。同步补齐 tenantMode 共享契约与 OpenAPI 产物。\n\n验证:Web 测试 129 项通过;pnpm lint 通过;Web 生产构建通过;pnpm openapi 通过。
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user