diff --git a/api-report/apps/api/src/modules/org/contract.d.ts b/api-report/apps/api/src/modules/org/contract.d.ts index a63caa7..f419257 100644 --- a/api-report/apps/api/src/modules/org/contract.d.ts +++ b/api-report/apps/api/src/modules/org/contract.d.ts @@ -74,3 +74,27 @@ export type OrgService = { staffOfPosition(positionCode: string): Promise; ensureSeeded(): Promise; }; +/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */ +export type Identity = { + readonly sub: string; + /** preferred_username */ + readonly account: string; + /** name claim,缺则回退 account */ + readonly name: string; + readonly email?: string; + /** realm 角色,噪声角色已滤、已排序 */ + readonly roles: readonly string[]; +}; +/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */ +export declare class TokenRejectedError extends Error { + constructor(message: string); +} +/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */ +export declare class AccountDisabledError extends Error { + readonly account: string; + constructor(account: string); +} +/** 角色→端改完之后没有任何在册员工能进运营中心 —— 那张表就再也没人改得了 */ +export declare class AccessLockoutError extends Error { + constructor(); +} 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 aaa64e7..95bcc39 100644 --- a/api-report/apps/api/src/modules/org/index.d.ts +++ b/api-report/apps/api/src/modules/org/index.d.ts @@ -11,3 +11,10 @@ export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/dom export { createGrantRepository, createPositionRepository, createSessionRepository, createStaffRepository, createWorkGroupRepository, objectKeyOf, } from './internal/adapter/org-repository.ts'; export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts'; export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; +export type { Identity } from './contract.ts'; +export { AccessLockoutError, AccountDisabledError, TokenRejectedError } from './contract.ts'; +export type { IdentityPatch, RosterSource, RosterUser, TokenVerifier, VerifiedClaims } from './port.ts'; +export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; +export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { buildInFlightWork } from './internal/domain/in-flight.ts'; +export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; diff --git a/api-report/apps/api/src/modules/org/port.d.ts b/api-report/apps/api/src/modules/org/port.d.ts index 791f337..3f0baaf 100644 --- a/api-report/apps/api/src/modules/org/port.d.ts +++ b/api-report/apps/api/src/modules/org/port.d.ts @@ -3,6 +3,7 @@ * adapter 实现之,usecase 依赖之,app 装配之。 */ import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts'; +import type { Identity } from './contract.ts'; export type Clock = { now(): Date; }; @@ -79,3 +80,32 @@ export type WorkGroupRepository = { export type IdGenerator = { next(): string; }; +/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */ +export type VerifiedClaims = { + readonly sub: string; + readonly preferred_username: string; + readonly name?: string; + readonly email?: string; + readonly realm_access?: { + readonly roles?: readonly string[]; + }; +}; +/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */ +export type TokenVerifier = { + verify(token: string): Promise; +}; +/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */ +export type RosterUser = Identity & { + readonly enabled: boolean; +}; +export type RosterSource = { + listUsers(): Promise; +}; +/** 名册即时更新 / 同步要改的那几列 */ +export type IdentityPatch = { + readonly account: string; + readonly name: string; + readonly email?: string; + readonly roles: readonly string[]; + readonly lastSeenAt?: Date; +}; diff --git a/apps/api/src/modules/org/__tests__/claims.test.ts b/apps/api/src/modules/org/__tests__/claims.test.ts new file mode 100644 index 0000000..559c3c9 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/claims.test.ts @@ -0,0 +1,45 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import { identityOf, isNoiseRole, normalizeRoles } from '../internal/domain/claims.ts'; + +describe('identityOf —— claims 归一成一个人', () => { + it('完整 claims:sub / 用户名 / 姓名 / 邮箱 / 角色(滤噪声、排序、去重)', () => { + const id = identityOf({ + sub: 'sub-1', + preferred_username: 'alice', + name: '爱丽丝', + email: 'a@x.test', + realm_access: { roles: ['user', 'default-roles-corp', 'offline_access', 'uma_authorization', 'admin', 'user'] }, + }); + expect(id).toEqual({ sub: 'sub-1', account: 'alice', name: '爱丽丝', email: 'a@x.test', roles: ['admin', 'user'] }); + }); + + it('没有 name(服务账号常见)→ 用用户名;没有角色 → 空数组;没有邮箱 → 不带键', () => { + const id = identityOf({ sub: 'sub-svc', preferred_username: 'service-account-bpos-e2e' }); + expect(id.name).toBe('service-account-bpos-e2e'); + expect(id.roles).toEqual([]); + expect('email' in id).toBe(false); + }); + + it('name 只有空白 → 同样回退;email 空串 → 不带键', () => { + const id = identityOf({ sub: 's', preferred_username: 'bob', name: ' ', email: '' }); + expect(id.name).toBe('bob'); + expect('email' in id).toBe(false); + }); + + it('realm_access 有但 roles 缺 → 空角色', () => { + expect(identityOf({ sub: 's', preferred_username: 'bob', realm_access: {} }).roles).toEqual([]); + }); +}); + +describe('噪声角色', () => { + it('三种家具角色都算噪声,业务角色不算', () => { + expect(isNoiseRole('default-roles-corp')).toBe(true); + expect(isNoiseRole('offline_access')).toBe(true); + expect(isNoiseRole('uma_authorization')).toBe(true); + expect(isNoiseRole('admin')).toBe(false); + }); + it('normalizeRoles 去重、排序、丢空串', () => { + expect(normalizeRoles(['user', '', 'admin', 'user'])).toEqual(['admin', 'user']); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/ends.test.ts b/apps/api/src/modules/org/__tests__/ends.test.ts new file mode 100644 index 0000000..bcb758e --- /dev/null +++ b/apps/api/src/modules/org/__tests__/ends.test.ts @@ -0,0 +1,55 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { RoleAccess } from '@contracts'; +import { AccessLockoutError } from '../contract.ts'; +import { assertNotLockedOut, endsOf, normalizeEnds } from '../internal/domain/ends.ts'; + +const table: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, + { role: 'designer', ends: ['design'] }, +]; + +describe('endsOf —— 角色命中行的并集', () => { + it('单角色', () => { + expect(endsOf(['user'], table)).toEqual(['workbench']); + }); + it('多角色并集,按 SURFACES 顺序', () => { + expect(endsOf(['user', 'designer'], table)).toEqual(['design', 'workbench']); + }); + it('没有任何行命中 → 空(无端可进)', () => { + expect(endsOf(['auditor'], table)).toEqual([]); + }); + it('表空 → 空', () => { + expect(endsOf(['admin'], [])).toEqual([]); + }); +}); + +describe('assertNotLockedOut —— 改完得有人还能进运营中心', () => { + it('名册空着不算锁死', () => { + expect(() => assertNotLockedOut([], [])).not.toThrow(); + }); + it('唯一的 admin 被摘掉 ops → 拒', () => { + const next: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, + ]; + expect(() => assertNotLockedOut(next, [['admin'], ['user']])).toThrow(AccessLockoutError); + }); + it('ops 换到另一个在册角色上 → 过', () => { + const next: RoleAccess[] = [ + { role: 'admin', ends: ['design'] }, + { role: 'ops-lead', ends: ['ops'] }, + ]; + expect(() => assertNotLockedOut(next, [['admin'], ['ops-lead']])).not.toThrow(); + }); + it('有 ops 行但没人持有那个角色 → 拒', () => { + expect(() => assertNotLockedOut([{ role: 'ghost', ends: ['ops'] }], [['user']])).toThrow(AccessLockoutError); + }); +}); + +describe('normalizeEnds', () => { + it('去重、按固定顺序', () => { + expect(normalizeEnds(['workbench', 'design', 'workbench'])).toEqual(['design', 'workbench']); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/roster-diff.test.ts b/apps/api/src/modules/org/__tests__/roster-diff.test.ts new file mode 100644 index 0000000..09e8c28 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/roster-diff.test.ts @@ -0,0 +1,67 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { Staff } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts'; +import type { RosterUser } from '../port.ts'; + +const none = positionIdSchema.parse('none'); +const local = (sub: string, over: Partial = {}): Staff => ({ + id: staffIdSchema.parse(sub), + account: sub, + name: sub, + positionId: none, + state: 'active', + roles: ['user'], + ...over, +}); +const remote = (sub: string, over: Partial = {}): RosterUser => ({ + sub, + account: sub, + name: sub, + roles: ['user'], + enabled: true, + ...over, +}); + +describe('diffRoster —— 远端说了算', () => { + it('远端有本地无 → 插', () => { + const d = diffRoster([remote('a')], []); + expect(d.insert.map((u) => u.sub)).toEqual(['a']); + expect(d.update).toEqual([]); + expect(d.setState).toEqual([]); + }); + it('改名 / 改角色 → 改;没变的不出现', () => { + const d = diffRoster([remote('a', { name: '阿尔法', roles: ['admin', 'user'] }), remote('b')], [local('a'), local('b')]); + expect(d.update).toEqual([{ id: 'a', patch: { account: 'a', name: '阿尔法', roles: ['admin', 'user'] } }]); + expect(d.setState).toEqual([]); + }); + it('远端 enabled=false → 停;本地已停不重复', () => { + const d = diffRoster( + [remote('a', { enabled: false }), remote('b', { enabled: false })], + [local('a'), local('b', { state: 'disabled' })], + ); + expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]); + }); + it('本地有远端无(Keycloak 删了人)→ 停', () => { + const d = diffRoster([], [local('a'), local('b', { state: 'disabled' })]); + expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]); + }); + it('远端又启用了 → 复活', () => { + const d = diffRoster([remote('a')], [local('a', { state: 'disabled' })]); + expect(d.setState).toEqual([{ id: 'a', state: 'active' }]); + }); +}); + +describe('identityChanged / sameRoles', () => { + it('邮箱从无到有算变', () => { + expect(identityChanged(local('a'), { sub: 'a', account: 'a', name: 'a', roles: ['user'], email: 'a@x' })).toBe(true); + }); + it('一模一样不算变', () => { + expect(identityChanged(local('a'), { sub: 'a', account: 'a', name: 'a', roles: ['user'] })).toBe(false); + }); + it('角色顺序不同不算变', () => { + expect(sameRoles(['a', 'b'], ['b', 'a'])).toBe(true); + expect(sameRoles(['a'], ['a', 'b'])).toBe(false); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/test-account.test.ts b/apps/api/src/modules/org/__tests__/test-account.test.ts new file mode 100644 index 0000000..5494c5e --- /dev/null +++ b/apps/api/src/modules/org/__tests__/test-account.test.ts @@ -0,0 +1,20 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import { staffIdSchema } from '@contracts'; +import { isTestAccount, soloGroupOf } from '../internal/domain/test-account.ts'; + +describe('测试账号策略(ADR-011 语义原样)', () => { + it('用户名比对不分大小写、忽略首尾空白;空串永远不算', () => { + expect(isTestAccount('AI', ['ai', ' chaihao '])).toBe(true); + expect(isTestAccount('Chaihao', ['ai', ' chaihao '])).toBe(true); + expect(isTestAccount('fengbing', ['ai'])).toBe(false); + expect(isTestAccount('', [''])).toBe(false); + }); + it('独立工作组:五个业务岗全指向本人,id 由用户名铸', () => { + const g = soloGroupOf({ id: staffIdSchema.parse('sub-ai'), account: 'service-account-bpos-e2e', name: 'AI' }); + expect(g.id).toBe('wg-solo-service-account-bpos-e2e'); + expect(g.ownerId).toBe('sub-ai'); + expect(g.name).toBe('AI 独立测试组'); + expect(g.members).toEqual({ PM: 'sub-ai', BD: 'sub-ai', FAE: 'sub-ai', PRM: 'sub-ai', OP: 'sub-ai' }); + }); +}); diff --git a/apps/api/src/modules/org/contract.ts b/apps/api/src/modules/org/contract.ts index 6f66e0e..dab26fc 100644 --- a/apps/api/src/modules/org/contract.ts +++ b/apps/api/src/modules/org/contract.ts @@ -93,3 +93,41 @@ export type OrgService = { staffOfPosition(positionCode: string): Promise; ensureSeeded(): Promise; }; + +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */ +export type Identity = { + readonly sub: string; + /** preferred_username */ + readonly account: string; + /** name claim,缺则回退 account */ + readonly name: string; + readonly email?: string; + /** realm 角色,噪声角色已滤、已排序 */ + readonly roles: readonly string[]; +}; + +/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */ +export class TokenRejectedError extends Error { + constructor(message: string) { + super(message); + this.name = 'TokenRejectedError'; + } +} + +/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */ +export class AccountDisabledError extends Error { + constructor(readonly account: string) { + super(`账号已在鉴权中心停用:${account} —— 若已恢复,请在运营中心同步一次名册`); + this.name = 'AccountDisabledError'; + } +} + +/** 角色→端改完之后没有任何在册员工能进运营中心 —— 那张表就再也没人改得了 */ +export class AccessLockoutError extends Error { + constructor() { + super('改完之后没有任何在册员工能进运营中心 —— 这张表就再也改不回来了,拒绝'); + this.name = 'AccessLockoutError'; + } +} diff --git a/apps/api/src/modules/org/index.ts b/apps/api/src/modules/org/index.ts index 84afd7e..4a7679b 100644 --- a/apps/api/src/modules/org/index.ts +++ b/apps/api/src/modules/org/index.ts @@ -51,3 +51,12 @@ export { } from './internal/adapter/org-repository.ts'; export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts'; export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; + +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ +export type { Identity } from './contract.ts'; +export { AccessLockoutError, AccountDisabledError, TokenRejectedError } from './contract.ts'; +export type { IdentityPatch, RosterSource, RosterUser, TokenVerifier, VerifiedClaims } from './port.ts'; +export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; +export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { buildInFlightWork } from './internal/domain/in-flight.ts'; +export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; diff --git a/apps/api/src/modules/org/internal/domain/claims.ts b/apps/api/src/modules/org/internal/domain/claims.ts new file mode 100644 index 0000000..0015c65 --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/claims.ts @@ -0,0 +1,33 @@ +/** + * token claims → 一个人(纯函数,零 IO;ADR-012)。 + * + * 只认手册 §17 列出的那几个 claim;`name` 缺了回退用户名(服务账号常常没有 name)。 + * Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉: + * `default-roles-` / `offline_access` / `uma_authorization`。 + */ +import type { Identity } from '../../contract.ts'; +import type { VerifiedClaims } from '../../port.ts'; + +const NOISE_ROLES: ReadonlySet = new Set(['offline_access', 'uma_authorization']); + +export function isNoiseRole(role: string): boolean { + return NOISE_ROLES.has(role) || role.startsWith('default-roles-'); +} + +/** 去重、滤噪声、排序 —— 角色快照要能逐字比对(「变了没有」靠它) */ +export function normalizeRoles(roles: readonly string[]): string[] { + return [...new Set(roles.filter((r) => r.trim() !== '' && !isNoiseRole(r)))].sort(); +} + +export function identityOf(claims: VerifiedClaims): Identity { + const account = claims.preferred_username; + const name = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim(); + const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim(); + return { + sub: claims.sub, + account, + name, + roles: normalizeRoles(claims.realm_access?.roles ?? []), + ...(email === undefined ? {} : { email }), + }; +} diff --git a/apps/api/src/modules/org/internal/domain/ends.ts b/apps/api/src/modules/org/internal/domain/ends.ts new file mode 100644 index 0000000..0e2a846 --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/ends.ts @@ -0,0 +1,33 @@ +/** + * 角色 → 端(纯函数,零 IO;ADR-012)。 + * + * 一个人的端 = 他的角色命中的每一行的并集。表可以空、角色可以一行都不命中 —— 那就是「无端可进」。 + * **锁死不变量**:改完之后至少一名在册活跃员工仍能进运营中心,否则这张表再也没人改得了。 + */ +import type { RoleAccess, Surface } from '@contracts'; +import { SURFACES } from '@contracts'; +import { AccessLockoutError } from '../../contract.ts'; + +export function endsOf(roles: readonly string[], table: readonly RoleAccess[]): Surface[] { + const hit = new Set(); + for (const row of table) { + if (!roles.includes(row.role)) continue; + for (const end of row.ends) hit.add(end); + } + return SURFACES.filter((s) => hit.has(s)); +} + +/** 名册空着(全新安装)不算锁死 —— 那时缺省表还在,第一个人进来就能改 */ +export function assertNotLockedOut( + table: readonly RoleAccess[], + activeStaffRoles: ReadonlyArray, +): void { + if (activeStaffRoles.length === 0) return; + const someoneKeepsOps = activeStaffRoles.some((roles) => endsOf(roles, table).includes('ops')); + if (!someoneKeepsOps) throw new AccessLockoutError(); +} + +/** 一行存进表里的样子只有一种:去重、按 SURFACES 的顺序 */ +export function normalizeEnds(ends: readonly Surface[]): Surface[] { + return SURFACES.filter((s) => ends.includes(s)); +} diff --git a/apps/api/src/modules/org/internal/domain/in-flight.ts b/apps/api/src/modules/org/internal/domain/in-flight.ts new file mode 100644 index 0000000..8635aa3 --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/in-flight.ts @@ -0,0 +1,24 @@ +/** + * 在途工作(纯逻辑,零 IO;OS 02:员工停用前,其在途任务与待答卡须先改派完毕)。 + * 停用本身已归鉴权中心(ADR-012),这里只剩「摆给人看」:此人身上还挂着什么。 + */ +import type { InFlightWork, StaffId } from '@contracts'; + +export type InFlightInput = { + readonly openTasks: ReadonlyArray<{ taskId: string; title: string }>; + readonly openCards: ReadonlyArray<{ cardId: string; title: string }>; + readonly openApprovals: ReadonlyArray<{ approvalId: string; title: string }>; +}; + +export function buildInFlightWork(staffId: StaffId, work: InFlightInput): InFlightWork { + const openTasks = [...work.openTasks]; + const openCards = [...work.openCards]; + const openApprovals = [...work.openApprovals]; + return { + staffId, + blocked: openTasks.length > 0 || openCards.length > 0 || openApprovals.length > 0, + openTasks, + openCards, + openApprovals, + }; +} diff --git a/apps/api/src/modules/org/internal/domain/roster-diff.ts b/apps/api/src/modules/org/internal/domain/roster-diff.ts new file mode 100644 index 0000000..b00a9ec --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/roster-diff.ts @@ -0,0 +1,63 @@ +/** + * 名册同步的差异(纯函数,零 IO;ADR-012)。 + * + * 远端(Keycloak Admin API)说了算: + * 不在本地 → 插;字段变了 → 改;远端 enabled=false 或**根本不在远端** → 停;远端又启用 → 复活。 + * 没变的不出现在结果里 —— 同步一次不该把全表重写一遍。 + */ +import type { Staff, StaffId } from '@contracts'; +import type { Identity } from '../../contract.ts'; +import type { IdentityPatch, RosterUser } from '../../port.ts'; + +export type RosterDiff = { + readonly insert: RosterUser[]; + readonly update: Array<{ readonly id: StaffId; readonly patch: IdentityPatch }>; + readonly setState: Array<{ readonly id: StaffId; readonly state: Staff['state'] }>; +}; + +export function sameRoles(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const sa = [...a].sort(); + const sb = [...b].sort(); + return sa.every((x, i) => x === sb[i]); +} + +/** 账号 / 姓名 / 邮箱 / 角色任一不同即「变了」;state 不在此列(它归同步) */ +export function identityChanged(staff: Staff, identity: Identity): boolean { + return ( + staff.account !== identity.account || + staff.name !== identity.name || + staff.email !== identity.email || + !sameRoles(staff.roles, identity.roles) + ); +} + +export function patchOf(identity: Identity): IdentityPatch { + return { + account: identity.account, + name: identity.name, + roles: [...identity.roles], + ...(identity.email === undefined ? {} : { email: identity.email }), + }; +} + +export function diffRoster(remote: readonly RosterUser[], local: readonly Staff[]): RosterDiff { + const bySub = new Map(local.map((s) => [s.id, s])); + const diff: RosterDiff = { insert: [], update: [], setState: [] }; + const seen = new Set(); + for (const user of remote) { + seen.add(user.sub); + const existing = bySub.get(user.sub); + if (existing === undefined) { + diff.insert.push(user); + continue; + } + if (identityChanged(existing, user)) diff.update.push({ id: existing.id, patch: patchOf(user) }); + const wanted: Staff['state'] = user.enabled ? 'active' : 'disabled'; + if (existing.state !== wanted) diff.setState.push({ id: existing.id, state: wanted }); + } + for (const s of local) { + if (!seen.has(s.id) && s.state !== 'disabled') diff.setState.push({ id: s.id, state: 'disabled' }); + } + return diff; +} diff --git a/apps/api/src/modules/org/internal/domain/seed-data.ts b/apps/api/src/modules/org/internal/domain/seed-data.ts index b610e52..cbc5891 100644 --- a/apps/api/src/modules/org/internal/domain/seed-data.ts +++ b/apps/api/src/modules/org/internal/domain/seed-data.ts @@ -2,6 +2,7 @@ * 种子数据(README《种子数据》)—— 纯常量,零 IO。 * 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。 */ +import type { RoleAccess } from '@contracts'; export const POSITION_SEED = [ { code: 'PM', name: '项目经理', note: '定价审批、策略拍板' }, @@ -15,6 +16,8 @@ export const POSITION_SEED = [ * 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。 */ { code: 'TEST', name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' }, + /** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */ + { code: 'NONE', name: '未分配', note: '未指派岗位;流程包不得声明它' }, ] as const; export const STAFF_SEED = [ @@ -89,3 +92,17 @@ export const SOLO_GROUP_ACCOUNTS = ['AI', 'Chaihao'] as const; /** 组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */ export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const; + +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +export const NONE_POSITION_CODE = 'NONE'; +export const TEST_POSITION_CODE = 'TEST'; + +/** + * 缺省的角色 → 端(表为空时播,之后由运营中心改): + * 与手册两个前端的口径一致 —— 用户端要 `user`、管理端要 `admin`。 + */ +export const DEFAULT_ROLE_ACCESS: readonly RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, +]; diff --git a/apps/api/src/modules/org/internal/domain/test-account.ts b/apps/api/src/modules/org/internal/domain/test-account.ts new file mode 100644 index 0000000..d26cd55 --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/test-account.ts @@ -0,0 +1,22 @@ +/** + * 测试账号策略(纯函数,零 IO;ADR-011 语义原样搬到 ADR-012 的名册上)。 + * + * 试跑与 e2e 用的账号要能一个人走完整条流:落 TEST 岗(不混进业务岗,免得轮值把真单派给它), + * 再建一个「所有业务岗都指向自己」的独立工作组。 + */ +import type { Staff, WorkGroup } from '@contracts'; +import { SOLO_GROUP_POSITIONS } from './seed-data.ts'; + +export function isTestAccount(account: string, testAccounts: readonly string[]): boolean { + const needle = account.trim().toLowerCase(); + return needle !== '' && testAccounts.some((a) => a.trim().toLowerCase() === needle); +} + +export function soloGroupOf(staff: Pick): WorkGroup { + return { + id: `wg-solo-${staff.account.toLowerCase()}`, + name: `${staff.name} 独立测试组`, + ownerId: staff.id, + members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])), + }; +} diff --git a/apps/api/src/modules/org/port.ts b/apps/api/src/modules/org/port.ts index bcda358..cd198be 100644 --- a/apps/api/src/modules/org/port.ts +++ b/apps/api/src/modules/org/port.ts @@ -3,6 +3,7 @@ * adapter 实现之,usecase 依赖之,app 装配之。 */ import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts'; +import type { Identity } from './contract.ts'; export type Clock = { now(): Date }; @@ -67,3 +68,30 @@ export type WorkGroupRepository = { /** ID 生成 —— domain 零随机(§5.3) */ export type IdGenerator = { next(): string }; + +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */ +export type VerifiedClaims = { + readonly sub: string; + readonly preferred_username: string; + readonly name?: string; + readonly email?: string; + readonly realm_access?: { readonly roles?: readonly string[] }; +}; + +/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */ +export type TokenVerifier = { verify(token: string): Promise }; + +/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */ +export type RosterUser = Identity & { readonly enabled: boolean }; +export type RosterSource = { listUsers(): Promise }; + +/** 名册即时更新 / 同步要改的那几列 */ +export type IdentityPatch = { + readonly account: string; + readonly name: string; + readonly email?: string; + readonly roles: readonly string[]; + readonly lastSeenAt?: Date; +};