From 4d55ac465d6e11b8d99c7ca2a9051e5704dfd128 Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 16:32:47 +0800 Subject: [PATCH] =?UTF-8?q?org=20adapter:Keycloak=20Admin=20API=20?= =?UTF-8?q?=E6=8B=89=E5=90=8D=E5=86=8C(provisioning=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7,=E5=88=86=E9=A1=B5=20100,=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=20view-users)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 master realm 的 provisioning 服务账号(client_credentials)取管理 token(闭包缓存、 提前 60 秒过期),分页拉全 realm 用户(默认 max=100,必须分页),逐人查 role-mappings/realm/composite 取实际生效的 realm 角色并滤噪声(复用 isNoiseRole)。 非 2xx 一律抛带状态码的错,不吞;secret 不落任何留痕。 --- .../apps/api/src/modules/org/index.d.ts | 2 + .../__tests__/keycloak-admin-roster.test.ts | 76 +++++++++++++++ apps/api/src/modules/org/index.ts | 3 + .../internal/adapter/keycloak-admin-roster.ts | 95 +++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts create mode 100644 apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts diff --git a/api-report/apps/api/src/modules/org/index.d.ts b/api-report/apps/api/src/modules/org/index.d.ts index 883af8b..5cb9ad7 100644 --- a/api-report/apps/api/src/modules/org/index.d.ts +++ b/api-report/apps/api/src/modules/org/index.d.ts @@ -20,3 +20,5 @@ export { buildInFlightWork } from './internal/domain/in-flight.ts'; export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; +export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; +export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; diff --git a/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts new file mode 100644 index 0000000..8689740 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts @@ -0,0 +1,76 @@ +/** Admin API 是外界 —— fetch 是注入的,这里给一个会分页、会发 token 的假 Keycloak */ +import { describe, expect, it } from 'vitest'; +import { createKeycloakRoster } from '../internal/adapter/keycloak-admin-roster.ts'; + +function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } { + const calls: string[] = []; + const users = Array.from({ length: 105 }, (_, i) => ({ + id: `sub-${i}`, + username: `u${i}`, + enabled: i !== 3, + firstName: i === 0 ? '' : '名', + lastName: i === 0 ? '' : `${i}`, + email: `u${i}@x.test`, + })); + const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + calls.push(`${init?.method ?? 'GET'} ${url}`); + if (url.endsWith('/realms/master/protocol/openid-connect/token')) { + return json({ access_token: 'admin-token', expires_in: 300 }); + } + const u = new URL(url); + if (u.pathname === '/admin/realms/corp/users') { + const first = Number(u.searchParams.get('first')); + const max = Number(u.searchParams.get('max')); + return json(users.slice(first, first + max)); + } + const m = /^\/admin\/realms\/corp\/users\/([^/]+)\/role-mappings\/realm\/composite$/.exec(u.pathname); + if (m !== null) { + return json([ + { name: 'user' }, + { name: 'default-roles-corp' }, + { name: 'offline_access' }, + ...(m[1] === 'sub-1' ? [{ name: 'admin' }] : []), + ]); + } + return json({ error: 'not found' }, 404); + }; + return { fetch: fetchImpl, calls }; +} + +describe('Keycloak Admin API 名册', () => { + it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { + const kc = fakeKeycloak(); + const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch }); + const users = await roster.listUsers(); + expect(users).toHaveLength(105); + expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true }); + expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '名 1', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); + expect(users[3]?.enabled).toBe(false); + expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2); + }); + + it('管理 token 只取一次(闭包缓存,提前 60 秒过期)', async () => { + const kc = fakeKeycloak(); + let t = 0; + const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch, now: () => t }); + await roster.listUsers(); + expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(1); + t = 241 * 1000; // expires_in 300 - 60 = 240 秒后才过期 + await roster.listUsers(); + expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(2); + }); + + it('Admin API 非 2xx → 抛带状态码的错(不吞)', async () => { + const fetchImpl: typeof fetch = async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.endsWith('/token')) return new Response(JSON.stringify({ access_token: 't', expires_in: 300 }), { status: 200 }); + return new Response('forbidden', { status: 403 }); + }; + const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'p', secret: 's', fetch: fetchImpl }); + await expect(roster.listUsers()).rejects.toThrow(/403/); + }); +}); diff --git a/apps/api/src/modules/org/index.ts b/apps/api/src/modules/org/index.ts index 7aaef6b..a253854 100644 --- a/apps/api/src/modules/org/index.ts +++ b/apps/api/src/modules/org/index.ts @@ -63,3 +63,6 @@ export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './i export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; + +export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; +export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts new file mode 100644 index 0000000..fbaf954 --- /dev/null +++ b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts @@ -0,0 +1,95 @@ +/** + * 名册来源:Keycloak Admin API(手册 §11 / §14;ADR-012)。 + * + * 用 master realm 的 provisioning 服务账号(client_credentials)取管理 token,只读: + * GET /admin/realms/{realm}/users?first&max=100(列表默认 max=100,必须分页) + * GET /users/{id}/role-mappings/realm/composite(实际生效的 realm 角色) + * 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。 + */ +import type { RosterSource, RosterUser } from '../../port.ts'; +import { isNoiseRole } from '../domain/claims.ts'; + +export type KeycloakRosterOptions = { + readonly url: string; + readonly realm: string; + readonly clientId: string; + readonly secret: string; + /** 测试注入;缺省全局 fetch */ + readonly fetch?: typeof fetch; + /** 毫秒时刻;测试注入 */ + readonly now?: () => number; +}; + +type KcUser = { + readonly id: string; + readonly username: string; + readonly enabled: boolean; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; +type KcRole = { readonly name: string }; + +const PAGE = 100; + +export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSource { + const fetchImpl = options.fetch ?? fetch; + const now = options.now ?? ((): number => Date.now()); + const base = options.url.replace(/\/+$/, ''); + let cached: { token: string; expiresAt: number } | undefined; + + async function adminToken(): Promise { + if (cached !== undefined && now() < cached.expiresAt) return cached.token; + const resp = await fetchImpl(`${base}/realms/master/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: options.clientId, + client_secret: options.secret, + }), + }); + if (!resp.ok) throw new Error(`取 Keycloak 管理 token 失败:${resp.status} ${await resp.text()}`); + const data = (await resp.json()) as { access_token: string; expires_in: number }; + // 提前 60 秒过期,免得边界上拿着一枚刚好失效的 token 去调 + cached = { token: data.access_token, expiresAt: now() + (data.expires_in - 60) * 1000 }; + return cached.token; + } + + async function adminGet(path: string): Promise { + const token = await adminToken(); + const resp = await fetchImpl(`${base}/admin/realms/${options.realm}${path}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!resp.ok) throw new Error(`Admin API GET ${path} 失败:${resp.status} ${await resp.text()}`); + return (await resp.json()) as T; + } + + return { + async listUsers(): Promise { + const users: KcUser[] = []; + for (let first = 0; ; first += PAGE) { + const page = await adminGet(`/users?first=${first}&max=${PAGE}&briefRepresentation=false`); + users.push(...page); + if (page.length < PAGE) break; + } + const out: RosterUser[] = []; + for (const u of users) { + const roles = await adminGet(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`); + const name = [u.firstName, u.lastName] + .filter((s): s is string => s !== undefined && s.trim() !== '') + .join(' ') + .trim(); + out.push({ + sub: u.id, + account: u.username, + name: name === '' ? u.username : name, + roles: [...new Set(roles.map((r) => r.name).filter((r) => !isNoiseRole(r)))].sort(), + enabled: u.enabled, + ...(u.email === undefined || u.email === '' ? {} : { email: u.email }), + }); + } + return out; + }, + }; +}