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": {
|
"tenantKey": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"tenantName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"userGroupId": {
|
"userGroupId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -12376,9 +12379,21 @@
|
|||||||
"supersededAt": {
|
"supersededAt": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"tenantContextAudience": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tenantContextEndpoint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tenantContextScope": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"tenantId": {
|
"tenantId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"tenantMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"tokenIntrospection": {
|
"tokenIntrospection": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
tenantKey:
|
tenantKey:
|
||||||
type: string
|
type: string
|
||||||
|
tenantName:
|
||||||
|
type: string
|
||||||
userGroupId:
|
userGroupId:
|
||||||
type: string
|
type: string
|
||||||
userGroupKey:
|
userGroupKey:
|
||||||
@@ -1858,8 +1860,16 @@ definitions:
|
|||||||
$ref: '#/definitions/identity.RevisionState'
|
$ref: '#/definitions/identity.RevisionState'
|
||||||
supersededAt:
|
supersededAt:
|
||||||
type: string
|
type: string
|
||||||
|
tenantContextAudience:
|
||||||
|
type: string
|
||||||
|
tenantContextEndpoint:
|
||||||
|
type: string
|
||||||
|
tenantContextScope:
|
||||||
|
type: string
|
||||||
tenantId:
|
tenantId:
|
||||||
type: string
|
type: string
|
||||||
|
tenantMode:
|
||||||
|
type: string
|
||||||
tokenIntrospection:
|
tokenIntrospection:
|
||||||
type: boolean
|
type: boolean
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
executeIdentityOperation,
|
executeIdentityOperation,
|
||||||
isStaleIdentityOperation,
|
isStaleIdentityOperation,
|
||||||
pairingFailureMessage,
|
pairingFailureMessage,
|
||||||
|
requiresLocalTenantMapping,
|
||||||
} from './UnifiedIdentityPanel';
|
} from './UnifiedIdentityPanel';
|
||||||
|
|
||||||
describe('UnifiedIdentityPanel', () => {
|
describe('UnifiedIdentityPanel', () => {
|
||||||
@@ -20,12 +21,26 @@ describe('UnifiedIdentityPanel', () => {
|
|||||||
expect(html).toContain('Gateway API 公网地址');
|
expect(html).toContain('Gateway API 公网地址');
|
||||||
expect(html).not.toContain('value="/gateway-api"');
|
expect(html).not.toContain('value="/gateway-api"');
|
||||||
expect(html).toContain('Gateway Web 地址');
|
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('Machine Client Secret');
|
||||||
expect(html).not.toContain('SSF Transmitter Issuer');
|
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', () => {
|
describe('PairingProgressCard', () => {
|
||||||
it('shows a safe actionable discovery failure and a recovery action', () => {
|
it('shows a safe actionable discovery failure and a recovery action', () => {
|
||||||
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} pairing={{
|
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} pairing={{
|
||||||
@@ -192,6 +207,7 @@ function identityRevision(
|
|||||||
id: `${state}-revision`,
|
id: `${state}-revision`,
|
||||||
state,
|
state,
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
tenantMode: 'single_tenant',
|
||||||
authCenterUrl: 'https://auth.example.com',
|
authCenterUrl: 'https://auth.example.com',
|
||||||
issuer: 'https://issuer.example.com',
|
issuer: 'https://issuer.example.com',
|
||||||
scopes: ['openid'],
|
scopes: ['openid'],
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function UnifiedIdentityPanel(props: { token: string }) {
|
|||||||
const [configuration, setConfiguration] = useState<IdentityConfigurationView | null>(null);
|
const [configuration, setConfiguration] = useState<IdentityConfigurationView | null>(null);
|
||||||
const [form, setForm] = useState<IdentityPairingInput>(defaultPairingInput);
|
const [form, setForm] = useState<IdentityPairingInput>(defaultPairingInput);
|
||||||
const [showPairingForm, setShowPairingForm] = useState(false);
|
const [showPairingForm, setShowPairingForm] = useState(false);
|
||||||
const [localTenantKey, setLocalTenantKey] = useState('default');
|
const [localTenantKey, setLocalTenantKey] = useState('');
|
||||||
const [legacyJwtEnabled, setLegacyJwtEnabled] = useState(false);
|
const [legacyJwtEnabled, setLegacyJwtEnabled] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
@@ -120,6 +120,7 @@ export function UnifiedIdentityPanel(props: { token: string }) {
|
|||||||
const previous = configuration?.previous;
|
const previous = configuration?.previous;
|
||||||
const pairing = configuration?.pairing;
|
const pairing = configuration?.pairing;
|
||||||
const runtime = configuration?.runtime;
|
const runtime = configuration?.runtime;
|
||||||
|
const localTenantMappingRequired = requiresLocalTenantMapping(draft?.tenantMode);
|
||||||
const cleanupPending = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'pending';
|
const cleanupPending = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'pending';
|
||||||
const cleanupCompleted = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'completed';
|
const cleanupCompleted = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'completed';
|
||||||
const shouldShowPairing = !active && (showPairingForm || !pairing || cleanupCompleted);
|
const shouldShowPairing = !active && (showPairingForm || !pairing || cleanupCompleted);
|
||||||
@@ -188,7 +189,9 @@ export function UnifiedIdentityPanel(props: { token: string }) {
|
|||||||
<RevisionDetails revision={draft} />
|
<RevisionDetails revision={draft} />
|
||||||
{draft.state === 'draft' && (
|
{draft.state === 'draft' && (
|
||||||
<div className="formGrid two">
|
<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">
|
<label className="identityCheckbox">
|
||||||
<input type="checkbox" checked={legacyJwtEnabled} onChange={(event) => setLegacyJwtEnabled(event.target.checked)} />
|
<input type="checkbox" checked={legacyJwtEnabled} onChange={(event) => setLegacyJwtEnabled(event.target.checked)} />
|
||||||
<span><strong>继续兼容 Legacy JWT</strong><small>仅在迁移期确有旧调用方时开启。</small></span>
|
<span><strong>继续兼容 Legacy JWT</strong><small>仅在迁移期确有旧调用方时开启。</small></span>
|
||||||
@@ -197,8 +200,8 @@ export function UnifiedIdentityPanel(props: { token: string }) {
|
|||||||
)}
|
)}
|
||||||
<div className="fileStorageToolbar">
|
<div className="fileStorageToolbar">
|
||||||
{draft.state === 'draft' && <>
|
{draft.state === 'draft' && <>
|
||||||
<Button type="button" variant="outline" disabled={loading || !localTenantKey.trim()} onClick={() => void saveDraftPolicy()}>保存本地策略</Button>
|
<Button type="button" variant="outline" disabled={loading || (localTenantMappingRequired && !localTenantKey.trim())} onClick={() => void saveDraftPolicy()}>保存本地策略</Button>
|
||||||
<Button type="button" disabled={loading || !localTenantKey.trim()}
|
<Button type="button" disabled={loading || (localTenantMappingRequired && !localTenantKey.trim())}
|
||||||
onClick={() => void run(() => validateIdentityRevision(props.token, draft.id, draft.version), '统一认证配置验证成功,可以激活。')}>
|
onClick={() => void run(() => validateIdentityRevision(props.token, draft.id, draft.version), '统一认证配置验证成功,可以激活。')}>
|
||||||
<ShieldCheck size={14} />验证配置
|
<ShieldCheck size={14} />验证配置
|
||||||
</Button>
|
</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>一次性接入码<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 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 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">
|
<label className="identityCheckbox">
|
||||||
<input type="checkbox" checked={form.legacyJwtEnabled} onChange={(event) => setForm({ ...form, legacyJwtEnabled: event.target.checked })} />
|
<input type="checkbox" checked={form.legacyJwtEnabled} onChange={(event) => setForm({ ...form, legacyJwtEnabled: event.target.checked })} />
|
||||||
<span><strong>继续兼容 Legacy JWT</strong><small>默认关闭;本地应急管理员登录始终保留。</small></span>
|
<span><strong>继续兼容 Legacy JWT</strong><small>默认关闭;本地应急管理员登录始终保留。</small></span>
|
||||||
@@ -368,7 +375,9 @@ function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision
|
|||||||
<span>Issuer: {revision.issuer || '等待 Manifest'}</span>
|
<span>Issuer: {revision.issuer || '等待 Manifest'}</span>
|
||||||
<span>Audience: {revision.audience || '等待 Manifest'}</span>
|
<span>Audience: {revision.audience || '等待 Manifest'}</span>
|
||||||
<span>Tenant: {revision.tenantId || '等待 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.browserClientId || '未启用'}</span>
|
||||||
<span>服务 Client: {revision.machineClientId || '未启用'}</span>
|
<span>服务 Client: {revision.machineClientId || '未启用'}</span>
|
||||||
<span>能力: {revision.capabilities.map(capabilityLabel).join('、') || '等待 Manifest'}</span>
|
<span>能力: {revision.capabilities.map(capabilityLabel).join('、') || '等待 Manifest'}</span>
|
||||||
@@ -392,11 +401,15 @@ function defaultPairingInput(): IdentityPairingInput {
|
|||||||
onboardingCode: '',
|
onboardingCode: '',
|
||||||
publicBaseUrl: /^https?:\/\//i.test(configuredApiBaseUrl) ? configuredApiBaseUrl : '',
|
publicBaseUrl: /^https?:\/\//i.test(configuredApiBaseUrl) ? configuredApiBaseUrl : '',
|
||||||
webBaseUrl,
|
webBaseUrl,
|
||||||
localTenantKey: 'default',
|
localTenantKey: '',
|
||||||
legacyJwtEnabled: false,
|
legacyJwtEnabled: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requiresLocalTenantMapping(tenantMode?: string): boolean {
|
||||||
|
return tenantMode !== 'multi_tenant';
|
||||||
|
}
|
||||||
|
|
||||||
function pairingStatusLabel(status: string, cleanupStatus?: string) {
|
function pairingStatusLabel(status: string, cleanupStatus?: string) {
|
||||||
if (status === 'cancelled' && cleanupStatus === 'pending') return '正在销毁临时凭据并清理由本次 Revision 创建的安全事件连接';
|
if (status === 'cancelled' && cleanupStatus === 'pending') return '正在销毁临时凭据并清理由本次 Revision 创建的安全事件连接';
|
||||||
return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record<string, string>)[status] ?? status;
|
return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record<string, string>)[status] ?? status;
|
||||||
|
|||||||
@@ -1053,6 +1053,7 @@ export interface IdentityConfigurationRevision {
|
|||||||
id: string;
|
id: string;
|
||||||
state: IdentityRevisionState;
|
state: IdentityRevisionState;
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
|
tenantMode: 'single_tenant' | 'multi_tenant';
|
||||||
authCenterUrl: string;
|
authCenterUrl: string;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user