org domain:claims 归一、角色→端与锁死不变量、名册差异、测试账号策略(ADR-012,纯函数)

为统一鉴权中心(Keycloak)落地铺地基:五个零 IO 的纯函数文件,后续 org 用例
重写(Task 8)直接复用。本任务只加不删 —— 旧的岗位授权 / 口令会话 / 停用检查
原样保留,现役 org-service 仍在用它们。

- claims.ts:token claims → Identity,滤掉三种 Keycloak 家具角色
- ends.ts:角色 → 端的并集,并守住「改完之后至少一人能进运营中心」这条锁死不变量
- roster-diff.ts:与 Keycloak 名册的插/改/停/复活差异(远端说了算)
- test-account.ts:测试账号判定与独立工作组构造(ADR-011 语义原样搬)
- in-flight.ts:停用前置检查要看的在途工作(与旧 disable-check.ts 并存一轮)

contract.ts / port.ts / index.ts / seed-data.ts 均为文件末尾追加,契约变更已
经 pnpm api:check --update 刷新快照。
This commit is contained in:
xcs
2026-08-25 16:09:10 +08:00
parent a5c8d4786d
commit 5108d7a580
16 changed files with 515 additions and 0 deletions
+24
View File
@@ -74,3 +74,27 @@ export type OrgService = {
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
ensureSeeded(): Promise<void>;
};
/** 从 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();
}
+7
View File
@@ -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';
+30
View File
@@ -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<VerifiedClaims>;
};
/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */
export type RosterUser = Identity & {
readonly enabled: boolean;
};
export type RosterSource = {
listUsers(): Promise<readonly RosterUser[]>;
};
/** 名册即时更新 / 同步要改的那几列 */
export type IdentityPatch = {
readonly account: string;
readonly name: string;
readonly email?: string;
readonly roles: readonly string[];
readonly lastSeenAt?: Date;
};
@@ -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']);
});
});
@@ -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']);
});
});
@@ -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> = {}): Staff => ({
id: staffIdSchema.parse(sub),
account: sub,
name: sub,
positionId: none,
state: 'active',
roles: ['user'],
...over,
});
const remote = (sub: string, over: Partial<RosterUser> = {}): 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);
});
});
@@ -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' });
});
});
+38
View File
@@ -93,3 +93,41 @@ export type OrgService = {
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
ensureSeeded(): Promise<void>;
};
/* ── 统一鉴权中心(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';
}
}
+9
View File
@@ -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';
@@ -0,0 +1,33 @@
/**
* token claims → 一个人(纯函数,零 IO;ADR-012)。
*
* 只认手册 §17 列出的那几个 claim;`name` 缺了回退用户名(服务账号常常没有 name)。
* Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉:
* `default-roles-<realm>` / `offline_access` / `uma_authorization`。
*/
import type { Identity } from '../../contract.ts';
import type { VerifiedClaims } from '../../port.ts';
const NOISE_ROLES: ReadonlySet<string> = 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 }),
};
}
@@ -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<Surface>();
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<readonly string[]>,
): 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));
}
@@ -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,
};
}
@@ -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<string, Staff>(local.map((s) => [s.id, s]));
const diff: RosterDiff = { insert: [], update: [], setState: [] };
const seen = new Set<string>();
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;
}
@@ -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'] },
];
@@ -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<Staff, 'id' | 'account' | 'name'>): 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])),
};
}
+28
View File
@@ -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<VerifiedClaims> };
/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */
export type RosterUser = Identity & { readonly enabled: boolean };
export type RosterSource = { listUsers(): Promise<readonly RosterUser[]> };
/** 名册即时更新 / 同步要改的那几列 */
export type IdentityPatch = {
readonly account: string;
readonly name: string;
readonly email?: string;
readonly roles: readonly string[];
readonly lastSeenAt?: Date;
};