统一鉴权中心接入(ADR-012):org 重写为 Keycloak 身份 + 角色→端;中间件按路由归属统一判;口令/会话/岗位授权退役;迁移 0032

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
This commit is contained in:
xcs
2026-08-25 17:40:55 +08:00
co-authored by Claude Opus 5
parent 81a185cd61
commit dd31b91829
38 changed files with 1236 additions and 1485 deletions
@@ -1,92 +0,0 @@
/** domain 测试零 mock(编码规范 §16) */
import { describe, expect, it } from 'vitest';
import type { Grant, PositionId } from '@contracts';
import { actionCovers, isAuthorized, objectCovers } from '../internal/domain/authorization.ts';
const pos = 'p-bd' as PositionId;
describe('actionCovers —— 动作蕴含', () => {
it('治理蕴含写与读', () => {
expect(actionCovers('govern', 'write')).toBe(true);
expect(actionCovers('govern', 'read')).toBe(true);
});
it('写蕴含读,但读不蕴含写', () => {
expect(actionCovers('write', 'read')).toBe(true);
expect(actionCovers('read', 'write')).toBe(false);
});
});
describe('objectCovers —— 客体三级覆盖', () => {
it('对象级授权覆盖其下字段', () => {
expect(
objectCovers(
{ kind: 'data', domain: 'shared', object: 'customer' },
{ kind: 'data', domain: 'shared', object: 'customer', field: 'grade' },
),
).toBe(true);
});
it('字段级授权不覆盖别的字段', () => {
expect(
objectCovers(
{ kind: 'data', domain: 'shared', object: 'customer', field: 'grade' },
{ kind: 'data', domain: 'shared', object: 'customer', field: 'payment_terms' },
),
).toBe(false);
});
it('域级通配覆盖域内全部对象', () => {
expect(
objectCovers(
{ kind: 'data', domain: 'shared', object: '*' },
{ kind: 'data', domain: 'shared', object: 'supplier', field: 'contact' },
),
).toBe(true);
});
it('跨域不覆盖', () => {
expect(
objectCovers(
{ kind: 'data', domain: 'shared', object: '*' },
{ kind: 'data', domain: 'quote_intake', object: 'record' },
),
).toBe(false);
});
it('治理功能逐项授权', () => {
expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'task' })).toBe(true);
expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'staff' })).toBe(false);
});
it('任务客体未限定活动即覆盖全部任务', () => {
expect(objectCovers({ kind: 'task' }, { kind: 'task', activityId: 'a1' })).toBe(true);
expect(objectCovers({ kind: 'task', activityId: 'a1' }, { kind: 'task', activityId: 'a2' })).toBe(false);
});
it('客体类型不同一律不覆盖', () => {
expect(objectCovers({ kind: 'task' }, { kind: 'governance', function: 'task' })).toBe(false);
});
});
describe('isAuthorized —— 任一授权覆盖即通过', () => {
const grants: Grant[] = [
{ positionId: pos, object: { kind: 'governance', function: 'task' }, action: 'read' },
{ positionId: pos, object: { kind: 'data', domain: 'shared', object: '*' }, action: 'write' },
];
it('命中数据域的写', () => {
expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'write')).toBe(true);
});
it('写权蕴含读权', () => {
expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'read')).toBe(true);
});
it('只读的治理功能不得写', () => {
expect(isAuthorized(grants, { kind: 'governance', function: 'task' }, 'govern')).toBe(false);
});
it('空授权一律不通过', () => {
expect(isAuthorized([], { kind: 'governance', function: 'system' }, 'read')).toBe(false);
});
});
@@ -0,0 +1,314 @@
/**
* usecase 测试:仓储全是内存实现(模块内部机制不 mock,只是换个存法),
* 验签是注入的假 verifier(Keycloak 是外界)。
*/
import { describe, expect, it } from 'vitest';
import type { Position, RoleAccess, WorkGroup } from '@contracts';
import { positionIdSchema, staffIdSchema } from '@contracts';
import { AccessLockoutError, AccountDisabledError, TokenRejectedError } from '../contract.ts';
import { createOrgService } from '../internal/usecase/org-service.ts';
import type { OrgServiceDeps } from '../internal/usecase/org-service.ts';
import type { RosterSource, StaffRecord, VerifiedClaims } from '../port.ts';
type Harness = {
org: ReturnType<typeof createOrgService>;
tokens: Map<string, VerifiedClaims>;
staff: Map<string, StaffRecord>;
roleAccess: Map<string, RoleAccess>;
groups: Map<string, WorkGroup>;
positions: Map<string, Position>;
calls: { updateIdentity: number };
advance(ms: number): void;
};
function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}): Harness {
const positions = new Map<string, Position>();
const staff = new Map<string, StaffRecord>();
const roleAccess = new Map<string, RoleAccess>();
const groups = new Map<string, WorkGroup>();
const tokens = new Map<string, VerifiedClaims>();
const calls = { updateIdentity: 0 };
let nowMs = Date.parse('2026-08-25T09:00:00Z');
let seq = 0;
const deps: OrgServiceDeps = {
verifier: {
verify: async (token) => {
const claims = tokens.get(token);
if (claims === undefined) throw new TokenRejectedError('签名校验失败');
return claims;
},
},
...(opts.roster === undefined ? {} : { roster: opts.roster }),
positions: {
list: async () => [...positions.values()],
findByCode: async (code) => [...positions.values()].find((p) => p.code === code),
findById: async (id) => positions.get(id),
upsert: async (p) => {
const id = p.id ?? positionIdSchema.parse(p.code.toLowerCase());
const row: Position = { id, code: p.code, name: p.name, ...(p.note === undefined ? {} : { note: p.note }) };
positions.set(id, row);
return row;
},
},
staff: {
list: async () => [...staff.values()],
findById: async (id) => staff.get(id),
listByPosition: async (pid) => [...staff.values()].filter((s) => s.positionId === pid),
insert: async (s, marks) => {
const row: StaffRecord = { ...s, ...marks };
staff.set(s.id, row);
return row;
},
updateIdentity: async (id, patch) => {
calls.updateIdentity += 1;
const { email: _dropped, ...rest } = staff.get(id)!;
staff.set(id, {
...rest,
account: patch.account,
name: patch.name,
roles: [...patch.roles],
...(patch.email === undefined ? {} : { email: patch.email }),
...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }),
});
},
setPosition: async (id, pid) => {
staff.set(id, { ...staff.get(id)!, positionId: pid });
},
setState: async (id, state, syncedAt) => {
staff.set(id, { ...staff.get(id)!, state, syncedAt });
},
},
roleAccess: {
list: async () => [...roleAccess.values()],
upsert: async (row) => {
roleAccess.set(row.role, row);
return row;
},
remove: async (role) => {
roleAccess.delete(role);
},
},
workGroups: {
listByOwner: async (owner) => [...groups.values()].filter((g) => g.ownerId === owner),
upsert: async (g) => {
groups.set(g.id, g);
return g;
},
},
inFlight: { openTasksOf: async () => [], openCardsOf: async () => [], openApprovalsOf: async () => [] },
clock: { now: () => new Date(nowMs) },
ids: { next: () => `id-${(seq += 1)}` },
testAccounts: opts.testAccounts ?? [],
};
return {
org: createOrgService(deps),
tokens,
staff,
roleAccess,
groups,
positions,
calls,
advance: (ms) => {
nowMs += ms;
},
};
}
const claims = (over: Partial<VerifiedClaims> & { sub: string; preferred_username: string }): VerifiedClaims => ({
name: over.preferred_username,
realm_access: { roles: ['user', 'default-roles-corp'] },
...over,
});
describe('authenticate —— 验签 → 名册即时更新 → 角色→端', () => {
it('首次见到:建档在 NONE 岗、active、角色滤了噪声、端按缺省表', async () => {
const h = harness();
await h.org.ensureSeeded();
h.tokens.set('t-alice', claims({ sub: 'sub-alice', preferred_username: 'alice', name: '爱丽丝', email: 'a@x.test' }));
const p = await h.org.authenticate('t-alice');
expect(p.staff.id).toBe('sub-alice');
expect(p.staff.account).toBe('alice');
expect(p.staff.name).toBe('爱丽丝');
expect(p.staff.email).toBe('a@x.test');
expect(p.staff.state).toBe('active');
expect(p.position.code).toBe('NONE');
expect(p.roles).toEqual(['user']);
expect(p.ends).toEqual(['workbench']);
});
it('admin → 三端', async () => {
const h = harness();
await h.org.ensureSeeded();
h.tokens.set('t', claims({ sub: 's', preferred_username: 'root', realm_access: { roles: ['admin'] } }));
expect((await h.org.authenticate('t')).ends).toEqual(['design', 'ops', 'workbench']);
});
it('坏 token → TokenRejectedError 原样抛出', async () => {
const h = harness();
await h.org.ensureSeeded();
await expect(h.org.authenticate('nope')).rejects.toBeInstanceOf(TokenRejectedError);
});
it('60 秒内再来且没变 → 不重写;改了名 → 写回并返回新名', async () => {
const h = harness();
await h.org.ensureSeeded();
h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃' }));
await h.org.authenticate('t');
h.advance(10_000);
await h.org.authenticate('t');
expect(h.calls.updateIdentity).toBe(0);
h.advance(61_000);
await h.org.authenticate('t');
expect(h.calls.updateIdentity).toBe(1); // 过了 60s 只为刷 last_seen_at 写一次
h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃·新' }));
const p = await h.org.authenticate('t');
expect(p.staff.name).toBe('鲍勃·新');
expect(h.calls.updateIdentity).toBe(2);
});
it('名册标了停用 → 即使 token 有效也抛 AccountDisabledError;principalOf 同款', async () => {
const h = harness();
await h.org.ensureSeeded();
h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob' }));
await h.org.authenticate('t');
h.staff.set('s', { ...h.staff.get('s')!, state: 'disabled' });
await expect(h.org.authenticate('t')).rejects.toBeInstanceOf(AccountDisabledError);
await expect(h.org.principalOf(staffIdSchema.parse('s'))).rejects.toBeInstanceOf(AccountDisabledError);
expect(await h.org.principalOf(staffIdSchema.parse('ghost'))).toBeUndefined();
});
it('测试账号:首次建档即 TEST 岗 + 独立工作组(五岗指向本人)', async () => {
const h = harness({ testAccounts: ['service-account-bpos-e2e'] });
await h.org.ensureSeeded();
h.tokens.set('t', claims({ sub: 'sub-ai', preferred_username: 'service-account-bpos-e2e', realm_access: { roles: ['admin'] } }));
const p = await h.org.authenticate('t');
expect(p.position.code).toBe('TEST');
const group = await h.org.workGroupOf(staffIdSchema.parse('sub-ai'));
expect(group?.members).toEqual({ PM: 'sub-ai', BD: 'sub-ai', FAE: 'sub-ai', PRM: 'sub-ai', OP: 'sub-ai' });
});
});
describe('名册与岗位', () => {
it('staffOfPosition 只回 active;NONE 恒回 []', async () => {
const h = harness();
await h.org.ensureSeeded();
const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] });
const b = await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: [] });
const bd = (await h.org.listStaff()).positions.find((p) => p.code === 'BD')!;
await h.org.setPosition(a.id, bd.id);
await h.org.setPosition(b.id, bd.id);
h.staff.set('b', { ...h.staff.get('b')!, state: 'disabled' });
expect((await h.org.staffOfPosition('BD')).map((s) => s.id)).toEqual(['a']);
expect(await h.org.staffOfPosition('NONE')).toEqual([]);
});
it('syncRoster 未配置 → configured:false', async () => {
const h = harness();
await h.org.ensureSeeded();
const outcome = await h.org.syncRoster();
expect(outcome.configured).toBe(false);
});
it('syncRoster:新增 / 改名 / 远端停用 / 本地有远端无 → 停;远端启用 → 复活', async () => {
const remote: RosterSource = {
listUsers: async () => [
{ sub: 'a', account: 'a', name: '阿', roles: ['user'], enabled: true },
{ sub: 'b', account: 'b', name: 'B', roles: ['user'], enabled: false },
{ sub: 'c', account: 'c', name: 'C', roles: ['admin'], enabled: true },
],
};
const h = harness({ roster: remote });
await h.org.ensureSeeded();
await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['user'] });
await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: ['user'] });
await h.org.registerIdentity({ sub: 'z', account: 'z', name: 'Z', roles: ['user'] });
h.staff.set('c', { ...h.staff.get('a')!, id: staffIdSchema.parse('c'), account: 'c', name: 'C', roles: ['admin'], state: 'disabled' });
const outcome = await h.org.syncRoster();
expect(outcome).toEqual({ configured: true, inserted: 0, updated: 1, disabled: 2, total: 3 });
expect(h.staff.get('a')?.name).toBe('阿');
expect(h.staff.get('b')?.state).toBe('disabled');
expect(h.staff.get('z')?.state).toBe('disabled');
expect(h.staff.get('c')?.state).toBe('active');
});
it('syncRoster 插入的新人也走测试账号策略,且远端停用的直接落 disabled', async () => {
const remote: RosterSource = {
listUsers: async () => [{ sub: 'ai', account: 'ai', name: 'AI', roles: ['admin'], enabled: true }, { sub: 'x', account: 'x', name: 'X', roles: [], enabled: false }],
};
const h = harness({ roster: remote, testAccounts: ['AI'] });
await h.org.ensureSeeded();
const outcome = await h.org.syncRoster();
expect(outcome).toEqual({ configured: true, inserted: 2, updated: 0, disabled: 0, total: 2 });
expect((await h.org.getStaff(staffIdSchema.parse('ai'))).position.code).toBe('TEST');
expect(h.staff.get('x')?.state).toBe('disabled');
});
});
describe('角色 → 端', () => {
it('listRoleAccess:knownRoles = 名册上见过的 ∪ 已配置的', async () => {
const h = harness();
await h.org.ensureSeeded();
await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['auditor'] });
const { roles, knownRoles } = await h.org.listRoleAccess();
expect(roles.map((r) => r.role).sort()).toEqual(['admin', 'user']);
expect(knownRoles).toEqual(['admin', 'auditor', 'user']);
});
it('setRoleAccess 归一端的顺序;唯一 admin 摘 ops → 锁死拒;换到别的在册角色 → 过', async () => {
const h = harness();
await h.org.ensureSeeded();
await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] });
await h.org.registerIdentity({ sub: 'lead', account: 'lead', name: 'L', roles: ['ops-lead'] });
expect((await h.org.setRoleAccess('designer', ['workbench', 'design'])).ends).toEqual(['design', 'workbench']);
await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).rejects.toBeInstanceOf(AccessLockoutError);
await h.org.setRoleAccess('ops-lead', ['ops']);
await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).resolves.toBeDefined();
await expect(h.org.deleteRoleAccess('ops-lead')).rejects.toBeInstanceOf(AccessLockoutError);
});
it('staffWithEnd:能进该端的活跃员工', async () => {
const h = harness();
await h.org.ensureSeeded();
await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] });
await h.org.registerIdentity({ sub: 'u', account: 'u', name: 'U', roles: ['user'] });
expect((await h.org.staffWithEnd('ops')).map((s) => s.id)).toEqual(['root']);
expect((await h.org.staffWithEnd('workbench')).map((s) => s.id).sort()).toEqual(['root', 'u']);
});
});
describe('ensureSeeded —— 幂等,不覆盖人工改动', () => {
it('岗位含 NONE 与 TEST;缺省两行只在表空时播;改过的行不被重播', async () => {
const h = harness();
await h.org.ensureSeeded();
const codes = (await h.org.listStaff()).positions.map((p) => p.code).sort();
expect(codes).toEqual(['BD', 'FAE', 'NONE', 'OP', 'PM', 'PRM', 'TEST']);
expect([...h.roleAccess.keys()].sort()).toEqual(['admin', 'user']);
h.roleAccess.set('user', { role: 'user', ends: [] });
await h.org.ensureSeeded();
expect(h.roleAccess.get('user')?.ends).toEqual([]);
expect(h.positions.size).toBe(7);
});
it('播种时给已在册的测试账号补策略', async () => {
const h = harness({ testAccounts: ['chaihao'] });
await h.org.ensureSeeded();
const none = (await h.org.listStaff()).positions.find((p) => p.code === 'NONE')!;
h.staff.set('ch', { id: staffIdSchema.parse('ch'), account: 'Chaihao', name: '柴浩', positionId: none.id, state: 'active', roles: [] });
await h.org.ensureSeeded();
expect((await h.org.getStaff(staffIdSchema.parse('ch'))).position.code).toBe('TEST');
expect((await h.org.workGroupOf(staffIdSchema.parse('ch')))?.id).toBe('wg-solo-chaihao');
});
});
describe('工作组(ADR-011 原样)', () => {
it('建组 / 取组 / 绑定快照', async () => {
const h = harness();
await h.org.ensureSeeded();
const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] });
const g = await h.org.saveWorkGroup({ name: 'A 组', ownerId: a.id, members: { BD: a.id } });
expect((await h.org.workGroupOf(a.id))?.id).toBe(g.id);
expect(await h.org.bindingOf(a.id)).toEqual({ BD: 'a' });
});
});
@@ -1,56 +0,0 @@
/**
* 口令散列(ADR-010)—— 守的是「明文不可逆、比对不早退、坏串不抛错」。
*/
import { describe, expect, it } from 'vitest';
import { hashPassword, newSessionToken, verifyPassword } from '../internal/adapter/password.ts';
describe('hashPassword / verifyPassword', () => {
it('散列串自描述参数,且不含明文', async () => {
const hash = await hashPassword('jcai-dev-2026');
expect(hash.split('$')).toHaveLength(6);
expect(hash.startsWith('scrypt$16384$8$1$')).toBe(true);
expect(hash).not.toContain('jcai-dev-2026');
});
it('同一口令两次散列不同 —— 盐是随机的', async () => {
expect(await hashPassword('same')).not.toBe(await hashPassword('same'));
});
it('对的口令过,错的不过', async () => {
const hash = await hashPassword('correct horse');
expect(await verifyPassword('correct horse', hash)).toBe(true);
expect(await verifyPassword('correct hors', hash)).toBe(false);
expect(await verifyPassword('', hash)).toBe(false);
});
it('未设口令(null / undefined)一律不过,不抛错', async () => {
// 「账号存在但没口令」不能变成一条能登进去的缝
expect(await verifyPassword('anything', null)).toBe(false);
expect(await verifyPassword('anything', undefined)).toBe(false);
});
it('坏串一律不过,不抛错 —— 登录失败的原因不外泄', async () => {
for (const bad of ['', 'plaintext', 'scrypt$x$8$1$aa$bb', 'bcrypt$16384$8$1$aa$bb', 'scrypt$16384$8$1$aa']) {
expect(await verifyPassword('anything', bad)).toBe(false);
}
});
it('按记录自己的参数重算 —— 日后调参不必迁移旧记录', async () => {
// 用一组非默认参数造串,再以默认参数的实现去验:能过才说明读的是串里的参数
const legacy = 'scrypt$1024$8$1$c2FsdHNhbHQ$';
expect(legacy.split('$')[1]).toBe('1024');
// 空 hash 段应判假(下面单独覆盖),这里只确认解析不炸
expect(await verifyPassword('x', legacy)).toBe(false);
});
});
describe('newSessionToken', () => {
it('不透明、够长、每次不同 —— 不编码任何身份信息', () => {
const a = newSessionToken();
const b = newSessionToken();
expect(a).not.toBe(b);
// 32 字节 base64url ≈ 43 字符
expect(a.length).toBeGreaterThanOrEqual(43);
expect(a).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
+75 -84
View File
@@ -1,17 +1,39 @@
/**
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
*
* 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。
* 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。
* 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。
*/
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts';
import type {
InFlightWork,
Position,
PositionId,
Principal,
RoleAccess,
RosterSyncOutcome,
Staff,
StaffId,
Surface,
WorkGroup,
WorkGroupBinding,
} from '@contracts';
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding };
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
export type StaffWithPosition = Staff & { readonly position: Position };
export type CreateStaffInput = {
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
export type Identity = {
readonly sub: string;
/** preferred_username */
readonly account: string;
/** name claim,缺则回退 account */
readonly name: string;
readonly positionId: string;
readonly email?: string;
/** realm 角色,噪声角色已滤、已排序 */
readonly roles: readonly string[];
};
export class StaffNotFoundError extends Error {
@@ -28,86 +50,6 @@ export class PositionNotFoundError extends Error {
}
}
export class AccountTakenError extends Error {
constructor(readonly account: string) {
super(`账号已存在:${account}`);
this.name = 'AccountTakenError';
}
}
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
export class DisableBlockedError extends Error {
constructor(readonly check: DisableCheck) {
super(`员工停用前置检查未过:在途任务 ${check.openTasks.length} 件、待答卡 ${check.openCards.length} 张`);
this.name = 'DisableBlockedError';
}
}
/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */
export type IssuedSession = {
readonly token: string;
readonly expiresAt: string;
};
export type OrgService = {
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
findByAccount(account: string): Promise<StaffWithPosition | undefined>;
createStaff(input: CreateStaffInput): Promise<Staff>;
/** 停用前置检查 —— 只读,治理台先看再点 */
checkDisable(staffId: StaffId): Promise<DisableCheck>;
/** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */
disableStaff(staffId: StaffId): Promise<void>;
listGrants(positionId: string): Promise<Grant[]>;
setGrants(positionId: string, grants: readonly Grant[]): Promise<Grant[]>;
/** 岗位权限校验 —— app 层鉴权中间件读它 */
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
loadSession(account: string): Promise<Session | undefined>;
/* ── 登录会话(ADR-010)—— 中间件在 app 层,身份判定读这里 ───────────── */
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */
login(account: string, password: string): Promise<IssuedSession | undefined>;
/** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */
logout(token: string): Promise<void>;
/** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */
resolveSession(token: string): Promise<Session | undefined>;
/** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */
setPassword(staffId: StaffId, password: string): Promise<void>;
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
/** 建组 / 改组;id 留空即新建 */
saveWorkGroup(input: {
id?: string;
name: string;
ownerId: StaffId;
members: Readonly<Record<string, StaffId>>;
}): Promise<WorkGroup>;
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
/** 装进工作流的快照:改组不改已起的单 */
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
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) {
@@ -131,3 +73,52 @@ export class AccessLockoutError extends Error {
this.name = 'AccessLockoutError';
}
}
export type OrgService = {
/* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
authenticate(token: string): Promise<Principal>;
/** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */
principalOf(staffId: StaffId): Promise<Principal | undefined>;
/** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */
registerIdentity(identity: Identity): Promise<Staff>;
/* ── 名册与岗位 ───────────────────────────────────────────────────── */
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
/** Admin API 同步;未配 provisioning 回 configured:false */
syncRoster(): Promise<RosterSyncOutcome>;
/** 此人身上还挂着什么(只读;OS 02 改派先于停用) */
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
/* ── 角色 → 端 ───────────────────────────────────────────────────── */
listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>;
/** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>;
deleteRoleAccess(role: string): Promise<void>;
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
/** 建组 / 改组;id 留空即新建 */
saveWorkGroup(input: {
id?: string;
name: string;
ownerId: StaffId;
members: Readonly<Record<string, StaffId>>;
}): Promise<WorkGroup>;
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
/** 装进工作流的快照:改组不改已起的单 */
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
/** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */
ensureSeeded(): Promise<void>;
};
+24 -32
View File
@@ -2,67 +2,59 @@
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
CreateStaffInput,
DisableCheck,
Grant,
GrantAction,
GrantObject,
IssuedSession,
Identity,
InFlightWork,
OrgService,
Position,
Session,
Principal,
RoleAccess,
RosterSyncOutcome,
Staff,
StaffWithPosition,
Surface,
WorkGroup,
WorkGroupBinding,
} from './contract.ts';
export {
AccountTakenError,
DisableBlockedError,
AccessLockoutError,
AccountDisabledError,
PositionNotFoundError,
StaffNotFoundError,
TokenRejectedError,
} from './contract.ts';
export type {
Clock,
GrantRepository,
IdGenerator,
IdentityPatch,
InFlightWorkProbe,
PasswordHasher,
PositionRepository,
SessionRepository,
RoleAccessRepository,
RosterSource,
RosterUser,
StaffRecord,
StaffRepository,
TokenVerifier,
VerifiedClaims,
WorkGroupRepository,
} from './port.ts';
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
export { createOrgService } from './internal/usecase/org-service.ts';
export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts';
export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts';
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';
/* ── 统一鉴权中心(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';
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, 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';
export {
createPositionRepository,
createRoleAccessRepository,
createStaffRepository,
createWorkGroupRepository,
} from './internal/adapter/org-repository.ts';
export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
@@ -1,24 +1,18 @@
/**
* org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。
*/
import { and, eq, gt } from 'drizzle-orm';
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
import { grantSchema, positionIdSchema, positionSchema, staffIdSchema, staffSchema, workGroupSchema } from '@contracts';
import { eq } from 'drizzle-orm';
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
import { SURFACES, positionIdSchema, positionSchema, staffSchema, workGroupSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { GrantRepository, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository } from '../../port.ts';
import { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './org-schema.ts';
/** 授权客体的规范化串 —— 主键的一部分,保证同一客体不重复授权 */
export function objectKeyOf(object: GrantObject): string {
switch (object.kind) {
case 'data':
return `data:${object.domain}.${object.object}${object.field === undefined ? '' : `.${object.field}`}`;
case 'governance':
return `governance:${object.function}`;
case 'task':
return `task${object.activityId === undefined ? '' : `:${object.activityId}`}`;
}
}
import type {
PositionRepository,
RoleAccessRepository,
StaffRecord,
StaffRepository,
WorkGroupRepository,
} from '../../port.ts';
import { positionTable, roleAccessTable, staffTable, workGroupTable } from './org-schema.ts';
export function createPositionRepository(db: Db): PositionRepository {
const toPosition = (row: typeof positionTable.$inferSelect): Position =>
@@ -59,109 +53,100 @@ export function createPositionRepository(db: Db): PositionRepository {
}
export function createStaffRepository(db: Db): StaffRepository {
const toStaff = (row: typeof staffTable.$inferSelect): Staff =>
staffSchema.parse({
const toRecord = (row: typeof staffTable.$inferSelect): StaffRecord => ({
...staffSchema.parse({
id: row.id,
account: row.account,
name: row.name,
...(row.email === null ? {} : { email: row.email }),
positionId: row.positionId,
state: row.state,
});
roles: row.roles,
}),
...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }),
...(row.syncedAt === null ? {} : { syncedAt: row.syncedAt }),
});
return {
async list() {
const rows = await db.select().from(staffTable).orderBy(staffTable.account);
return rows.map(toStaff);
return rows.map(toRecord);
},
async findById(id: StaffId) {
const rows = await db.select().from(staffTable).where(eq(staffTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toStaff(rows[0]);
},
async findByAccount(account: string) {
const rows = await db.select().from(staffTable).where(eq(staffTable.account, account)).limit(1);
return rows[0] === undefined ? undefined : toStaff(rows[0]);
return rows[0] === undefined ? undefined : toRecord(rows[0]);
},
async listByPosition(positionId: PositionId) {
const rows = await db.select().from(staffTable).where(eq(staffTable.positionId, positionId));
return rows.map(toStaff);
return rows.map(toRecord);
},
async insert(staff: Staff) {
const [row] = await db.insert(staffTable).values(staff).returning();
async insert(staff: Staff, marks) {
const [row] = await db
.insert(staffTable)
.values({
id: staff.id,
account: staff.account,
name: staff.name,
email: staff.email ?? null,
positionId: staff.positionId,
state: staff.state,
roles: [...staff.roles],
lastSeenAt: marks.lastSeenAt ?? null,
syncedAt: marks.syncedAt ?? null,
})
.returning();
if (row === undefined) throw new Error('员工写入未返回行');
return toStaff(row);
return toRecord(row);
},
async setState(id: StaffId, state) {
await db.update(staffTable).set({ state }).where(eq(staffTable.id, id));
async updateIdentity(id: StaffId, patch) {
await db
.update(staffTable)
.set({
account: patch.account,
name: patch.name,
email: patch.email ?? null,
roles: [...patch.roles],
...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }),
})
.where(eq(staffTable.id, id));
},
async passwordHashOf(account: string) {
const rows = await db
.select({ hash: staffTable.passwordHash })
.from(staffTable)
.where(eq(staffTable.account, account))
.limit(1);
return rows[0]?.hash ?? undefined;
async setPosition(id: StaffId, positionId: PositionId) {
await db.update(staffTable).set({ positionId }).where(eq(staffTable.id, id));
},
async setPasswordHash(id: StaffId, hash: string) {
await db.update(staffTable).set({ passwordHash: hash }).where(eq(staffTable.id, id));
async setState(id: StaffId, state, syncedAt: Date) {
await db.update(staffTable).set({ state, syncedAt }).where(eq(staffTable.id, id));
},
};
}
/** 会话仓储 —— 过期由 `expires_at` 判定,读时顺带滑动续期(ADR-010) */
export function createSessionRepository(db: Db): SessionRepository {
return {
async issue({ token, staffId, expiresAt }) {
await db.insert(sessionTable).values({ id: token, staffId, expiresAt });
},
async touch(token: string, now: Date, expiresAt: Date) {
const rows = await db
.update(sessionTable)
.set({ lastSeenAt: now, expiresAt })
.where(and(eq(sessionTable.id, token), gt(sessionTable.expiresAt, now)))
.returning({ staffId: sessionTable.staffId });
const row = rows[0];
return row === undefined ? undefined : { staffId: staffIdSchema.parse(row.staffId) };
},
async revoke(token: string) {
await db.delete(sessionTable).where(eq(sessionTable.id, token));
},
async revokeAllOf(staffId: StaffId) {
await db.delete(sessionTable).where(eq(sessionTable.staffId, staffId));
},
};
}
export function createGrantRepository(db: Db): GrantRepository {
const toGrant = (row: typeof grantTable.$inferSelect): Grant =>
grantSchema.parse({ positionId: row.positionId, object: row.object, action: row.action });
/** 角色 → 端:契约里是端名数组,表里是三列布尔 —— 这里来回翻译 */
export function createRoleAccessRepository(db: Db): RoleAccessRepository {
const toRow = (r: typeof roleAccessTable.$inferSelect): RoleAccess => ({
role: r.role,
ends: SURFACES.filter((s) => r[s]),
});
return {
async listByPosition(positionId: PositionId) {
const rows = await db
.select()
.from(grantTable)
.where(and(eq(grantTable.positionId, positionId), eq(grantTable.enabled, true)));
return rows.map(toGrant);
async list() {
const rows = await db.select().from(roleAccessTable).orderBy(roleAccessTable.role);
return rows.map(toRow);
},
async replaceForPosition(positionId: PositionId, grants) {
return db.transaction(async (tx) => {
await tx.delete(grantTable).where(eq(grantTable.positionId, positionId));
if (grants.length === 0) return [];
const rows = await tx
.insert(grantTable)
.values(
grants.map((g) => ({
positionId,
objectKey: objectKeyOf(g.object),
action: g.action,
object: g.object,
enabled: true,
})),
)
.onConflictDoNothing()
.returning();
return rows.map(toGrant);
});
async upsert(row: RoleAccess) {
const flags = {
design: row.ends.includes('design'),
ops: row.ends.includes('ops'),
workbench: row.ends.includes('workbench'),
};
const [saved] = await db
.insert(roleAccessTable)
.values({ role: row.role, ...flags })
.onConflictDoUpdate({ target: roleAccessTable.role, set: { ...flags, updatedAt: new Date() } })
.returning();
if (saved === undefined) throw new Error('角色→端写入未返回行');
return toRow(saved);
},
async remove(role: string) {
await db.delete(roleAccessTable).where(eq(roleAccessTable.role, role));
},
};
}
@@ -1,8 +1,9 @@
/**
* org 的 sys schema 表(编码规范 §7 / ADR-002:sys 由各模块 adapter 经 Drizzle 单写)。
* drizzle-kit 以 glob 收集本仓全部 `*-schema.ts`(见 drizzle.config.ts)。
* 会话表与岗位授权表已随 ADR-012 退役(迁移 0032 drop)。
*/
import { boolean, index, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { boolean, index, jsonb, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const positionTable = sys.table(
@@ -17,55 +18,36 @@ export const positionTable = sys.table(
(t) => [uniqueIndex('position_code_key').on(t.code)],
);
/** 员工 —— `id` 就是 Keycloak 的 sub(ADR-012);`account` 只作显示 */
export const staffTable = sys.table(
'staff',
{
id: text('id').primaryKey(),
account: text('account').notNull(),
name: text('name').notNull(),
email: text('email'),
positionId: text('position_id')
.notNull()
.references(() => positionTable.id),
/** 只由名册同步置 disabled;有效 token 不改它 */
state: text('state').notNull().default('active'),
/** 口令散列(ADR-010):`scrypt$N$r$p$salt$hash`,明文永不落库 */
passwordHash: text('password_hash'),
/** realm 角色快照(签发时;噪声角色已滤) */
roles: jsonb('roles').$type<string[]>().notNull().default([]),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
syncedAt: timestamp('synced_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)],
);
/**
* 会话(ADR-010)—— 服务端表而非签名 cookie:**登出必须立刻失效**,
* 且停用员工时要能一并踢掉在途会话。id 即 token,不透明随机串。
*/
export const sessionTable = sys.table(
'session',
{
id: text('id').primaryKey(),
staffId: text('staff_id')
.notNull()
.references(() => staffTable.id, { onDelete: 'cascade' }),
issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('session_staff_idx').on(t.staffId), index('session_expires_idx').on(t.expiresAt)],
);
export const grantTable = sys.table(
'position_grant',
{
positionId: text('position_id')
.notNull()
.references(() => positionTable.id, { onDelete: 'cascade' }),
/** 客体的规范化串:data:<域>.<对象>[.<字段>] / governance:<功能> / task[:<活动>] */
objectKey: text('object_key').notNull(),
action: text('action').notNull(),
object: jsonb('object').notNull(),
enabled: boolean('enabled').notNull().default(true),
},
(t) => [primaryKey({ columns: [t.positionId, t.objectKey, t.action] })],
);
/** 角色 → 端(ADR-012):一行一个 realm 角色,三列布尔 —— 运营中心可改 */
export const roleAccessTable = sys.table('role_access', {
role: text('role').primaryKey(),
design: boolean('design').notNull().default(false),
ops: boolean('ops').notNull().default(false),
workbench: boolean('workbench').notNull().default(false),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
/**
* 工作组(ADR-011)—— 一组「岗位 code → 员工 id」的绑定。
@@ -1,61 +0,0 @@
/**
* 口令散列(ADR-010)—— Node 内置 scrypt,零第三方依赖。
*
* 串格式自描述:`scrypt$N$r$p$<salt-b64url>$<hash-b64url>`。
* 参数写进串里,日后调参不必迁移旧记录:比对时按**该记录自己的**参数重算。
* 明文永不落库,比对走 `timingSafeEqual`(等长常数时间,不因前缀相同而早退)。
*/
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
/** 默认代价参数:N=16384 约 16MB 内存、单次 ~50ms —— 内网 ≤50 人足够 */
const N = 16_384;
const R = 8;
const P = 1;
const KEY_LEN = 32;
const SALT_LEN = 16;
const b64 = (buf: Buffer) => buf.toString('base64url');
function derive(password: string, salt: Buffer, n: number, r: number, p: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
// maxmem 需随 N 抬高:默认 32MB 挡不住 N=16384(约需 128*N*r ≈ 16MB,留两倍余量)
scrypt(password, salt, KEY_LEN, { N: n, r, p, maxmem: 256 * n * r }, (error, key) => {
if (error !== null) reject(error);
else resolve(key);
});
});
}
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(SALT_LEN);
const hash = await derive(password, salt, N, R, P);
return `scrypt$${N}$${R}$${P}$${b64(salt)}$${b64(hash)}`;
}
/**
* 比对。存的串不合格式 / 员工尚未设口令 → 一律 false,不抛错:
* 登录失败的原因不外泄(账号不存在、未设口令、口令错,对外都是同一句话)。
*/
export async function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean> {
if (stored === null || stored === undefined) return false;
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts;
const n = Number(nRaw);
const r = Number(rRaw);
const p = Number(pRaw);
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
const expected = Buffer.from(hashRaw ?? '', 'base64url');
if (expected.length === 0) return false;
const actual = await derive(password, Buffer.from(saltRaw ?? '', 'base64url'), n, r, p).catch(() => undefined);
if (actual === undefined || actual.length !== expected.length) return false;
return timingSafeEqual(actual, expected);
}
/** 会话 token —— 32 字节密码学随机,不透明(不编码任何身份信息) */
export function newSessionToken(): string {
return b64(randomBytes(32));
}
@@ -1,56 +0,0 @@
/**
* 岗位授权判定 —— 纯逻辑,零 IO(编码规范 §5.3)。
*
* 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。
* 语义出处:OS 02 员工管理。
*/
import type { Grant, GrantAction, GrantObject } from '@contracts';
/** 动作蕴含:治理 ⊃ 写 ⊃ 读 —— 有写权即可读,有治理权即可写 */
const IMPLIES: Readonly<Record<GrantAction, readonly GrantAction[]>> = {
govern: ['govern', 'write', 'read'],
write: ['write', 'read'],
read: ['read'],
};
export function actionCovers(granted: GrantAction, required: GrantAction): boolean {
return IMPLIES[granted].includes(required);
}
/**
* 客体覆盖:授权客体是否覆盖被请求客体。
* 数据客体分三级:域.对象.字段 —— 授到域即覆盖其下全部对象,授到对象即覆盖其下全部字段。
*/
export function objectCovers(granted: GrantObject, required: GrantObject): boolean {
if (granted.kind !== required.kind) return false;
switch (granted.kind) {
case 'data': {
const req = required as Extract<GrantObject, { kind: 'data' }>;
if (granted.domain !== req.domain) return false;
// 授权到域级(未指定对象名的通配 '*')
if (granted.object !== '*' && granted.object !== req.object) return false;
if (granted.field === undefined) return true;
return granted.field === req.field;
}
case 'governance': {
const req = required as Extract<GrantObject, { kind: 'governance' }>;
return granted.function === req.function;
}
case 'task': {
const req = required as Extract<GrantObject, { kind: 'task' }>;
// 未限定活动 = 覆盖全部任务
if (granted.activityId === undefined) return true;
return granted.activityId === req.activityId;
}
}
}
/** 任一授权覆盖即通过 */
export function isAuthorized(
grants: readonly Grant[],
object: GrantObject,
action: GrantAction,
): boolean {
return grants.some((g) => actionCovers(g.action, action) && objectCovers(g.object, object));
}
@@ -1,25 +0,0 @@
/**
* 停用前置检查 —— 纯逻辑,零 IO(编码规范 §5.3)。
*
* OS 02:员工停用前,其在途任务与待答卡须先改派完毕。
*/
import type { DisableCheck, StaffId } from '@contracts';
export type InFlightWork = {
readonly openTasks: ReadonlyArray<{ taskId: string; title: string }>;
readonly openCards: ReadonlyArray<{ cardId: string; title: string }>;
readonly openApprovals: ReadonlyArray<{ approvalId: string; title: string }>;
};
export function buildDisableCheck(staffId: StaffId, work: InFlightWork): DisableCheck {
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,
};
}
@@ -1,9 +1,13 @@
/**
* 种子数据(README《种子数据》)—— 纯常量,零 IO。
* 种子数据 —— 纯常量,零 IO。
* 人不再播种(名册来自鉴权中心,ADR-012);这里只有岗位与缺省的角色→端。
* 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。
*/
import type { RoleAccess } from '@contracts';
export const NONE_POSITION_CODE = 'NONE';
export const TEST_POSITION_CODE = 'TEST';
export const POSITION_SEED = [
{ code: 'PM', name: '项目经理', note: '定价审批、策略拍板' },
{ code: 'BD', name: '商务', note: 'RFQ 受理、成本定价、报价议价、情报归档' },
@@ -15,88 +19,12 @@ export const POSITION_SEED = [
* 混进去会让轮值把真单派到测试账号头上;单列则彼此不打扰 ——
* 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。
*/
{ code: 'TEST', name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' },
{ code: TEST_POSITION_CODE, name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' },
/** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */
{ code: 'NONE', name: '未分配', note: '未指派岗位;流程包不得声明它' },
{ code: NONE_POSITION_CODE, name: '未分配', note: '未指派岗位;流程包不得声明它' },
] as const;
export const STAFF_SEED = [
{ account: 'dingziqiao', name: '丁子乔', position: 'PM' },
{ account: 'fengbing', name: '冯兵', position: 'BD' },
{ account: 'wangziming', name: '王梓名', position: 'FAE' },
{ account: 'linpeixuan', name: '林佩萱', position: 'PRM' },
{ account: 'liyuanchu', name: '李元初', position: 'OP' },
// 两个测试账号:各自一个「全岗位指向自己」的工作组,起单即整条流归自己
{ account: 'AI', name: 'AI', position: 'TEST' },
{ account: 'Chaihao', name: '柴浩', position: 'TEST' },
] as const;
/** 登录者 = 冯兵(BD);MVP 单组织 cookie session(编码规范 §4 鉴权定调) */
export const DEFAULT_LOGIN_ACCOUNT = 'fengbing';
export type PositionSeed = (typeof POSITION_SEED)[number];
export type StaffSeed = (typeof STAFF_SEED)[number];
/**
* 岗位默认授权 —— 治理台九功能对 PM 全开,其余岗位只开与其职责相关的读。
* 任务负责人与被征答人对所涉内容**天然可见**,不靠这里授权(OS 02)。
*/
export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: string; action: 'read' | 'write' | 'govern' }>>> = {
/** 测试岗与 PM 同权:一个人要走完整条流,还得在治理台看过程 */
TEST: [
{ fn: 'task', action: 'govern' },
{ fn: 'staff', action: 'govern' },
{ fn: 'process', action: 'govern' },
{ fn: 'trigger', action: 'govern' },
{ fn: 'activity', action: 'govern' },
{ fn: 'tool', action: 'govern' },
{ fn: 'data', action: 'govern' },
{ fn: 'memory', action: 'govern' },
{ fn: 'system', action: 'govern' },
],
PM: [
{ fn: 'task', action: 'govern' },
{ fn: 'staff', action: 'govern' },
{ fn: 'process', action: 'govern' },
{ fn: 'trigger', action: 'govern' },
{ fn: 'activity', action: 'govern' },
{ fn: 'tool', action: 'govern' },
{ fn: 'data', action: 'govern' },
{ fn: 'memory', action: 'govern' },
{ fn: 'system', action: 'govern' },
],
BD: [
{ fn: 'task', action: 'read' },
{ fn: 'data', action: 'read' },
],
FAE: [
{ fn: 'task', action: 'read' },
{ fn: 'data', action: 'read' },
],
PRM: [
{ fn: 'task', action: 'read' },
{ fn: 'data', action: 'read' },
],
OP: [
{ fn: 'task', action: 'read' },
{ fn: 'data', action: 'read' },
{ fn: 'memory', action: 'read' },
],
};
/**
* 测试账号的**独立工作组**(ADR-011)—— 所有岗位都指向本人。
* 起单即整条流(A1…A7 + 审批点)全部落到自己工位,一个人从头走到尾。
*/
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';
/**
* 缺省的角色 → 端(表为空时播,之后由运营中心改):
@@ -106,3 +34,6 @@ export const DEFAULT_ROLE_ACCESS: readonly RoleAccess[] = [
{ role: 'admin', ends: ['design', 'ops', 'workbench'] },
{ role: 'user', ends: ['workbench'] },
];
/** 独立测试组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */
export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const;
@@ -1,51 +1,57 @@
/**
* org 用例 —— 编排 domain,依赖 port.ts 声明的能力,不 import 任何 adapter(编码规范 §5.3)。
*
* 身份归统一鉴权中心(ADR-012):
* authenticate = 验签 → claims 归一 → 名册即时更新(JIT)→ 角色 × 角色→端表 → Principal。
* `state` 由名册同步说了算,JIT 不碰它(插入时 active):本地 disabled 即使 token 有效也拒 ——
* 撤权在同步之后即时生效;Keycloak 恢复了就再同步一次。
*/
import type { Grant, GrantAction, GrantObject, Position, PositionId, Session, Staff, StaffId } from '@contracts';
import { governanceFunctionSchema, positionIdSchema, staffIdSchema } from '@contracts';
import type {
CreateStaffInput,
IssuedSession,
OrgService,
StaffWithPosition,
} from '../../contract.ts';
import {
AccountTakenError,
DisableBlockedError,
PositionNotFoundError,
StaffNotFoundError,
} from '../../contract.ts';
import type { Position, Principal, RoleAccess, Staff } from '@contracts';
import { positionIdSchema, staffIdSchema } from '@contracts';
import type { Identity, OrgService, RosterSyncOutcome, StaffWithPosition } from '../../contract.ts';
import { AccountDisabledError, PositionNotFoundError, StaffNotFoundError } from '../../contract.ts';
import type {
Clock,
GrantRepository,
IdGenerator,
InFlightWorkProbe,
PasswordHasher,
PositionRepository,
SessionRepository,
RoleAccessRepository,
RosterSource,
StaffRecord,
StaffRepository,
TokenVerifier,
WorkGroupRepository,
} from '../../port.ts';
import { isAuthorized } from '../domain/authorization.ts';
import { buildDisableCheck } from '../domain/disable-check.ts';
import { POSITION_GRANT_SEED, POSITION_SEED, SOLO_GROUP_ACCOUNTS, SOLO_GROUP_POSITIONS, STAFF_SEED } from '../domain/seed-data.ts';
import { identityOf } from '../domain/claims.ts';
import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts';
import { buildInFlightWork } from '../domain/in-flight.ts';
import { diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts';
import { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from '../domain/seed-data.ts';
import { isTestAccount, soloGroupOf } from '../domain/test-account.ts';
export type OrgServiceDeps = {
readonly verifier: TokenVerifier;
/** 未配 provisioning 服务账号时缺省 —— 同步按钮如实回「未配置」 */
readonly roster?: RosterSource;
readonly positions: PositionRepository;
readonly staff: StaffRepository;
readonly grants: GrantRepository;
readonly sessions: SessionRepository;
readonly roleAccess: RoleAccessRepository;
readonly workGroups: WorkGroupRepository;
readonly passwords: PasswordHasher;
readonly inFlight: InFlightWorkProbe;
readonly clock: Clock;
readonly ids: IdGenerator;
/** 种子口令 —— 由 app 从环境读入,源码不写任何真实口令(ADR-010) */
readonly seedPassword: string;
/** 测试账号(Keycloak 用户名):首次建档与播种时落 TEST 岗 + 独立工作组 */
readonly testAccounts: readonly string[];
};
/** 会话有效期 12 小时,每次命中滑动续期 */
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
/** `last_seen_at` 多久之内不重写 —— 每个请求一次写太贵 */
const LAST_SEEN_REWRITE_MS = 60_000;
/** 契约里的 Staff 没有那两枚时刻 —— 出模块前剥掉 */
function toStaff(record: StaffRecord): Staff {
const { lastSeenAt: _seen, syncedAt: _synced, ...staff } = record;
return staff;
}
export function createOrgService(deps: OrgServiceDeps): OrgService {
async function positionMap(): Promise<Map<string, Position>> {
@@ -60,142 +66,188 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
return { ...staff, position };
}
async function grantsOfStaff(staffId: StaffId): Promise<Grant[]> {
const staff = await deps.staff.findById(staffId);
if (!staff) throw new StaffNotFoundError(staffId);
return deps.grants.listByPosition(staff.positionId);
async function positionByCode(code: string): Promise<Position> {
const position = await deps.positions.findByCode(code);
if (!position) throw new PositionNotFoundError(code);
return position;
}
return {
async function principalFor(record: StaffRecord): Promise<Principal> {
if (record.state !== 'active') throw new AccountDisabledError(record.account);
const staff = toStaff(record);
const position = await deps.positions.findById(staff.positionId);
if (!position) throw new PositionNotFoundError(staff.positionId);
const table = await deps.roleAccess.list();
return { staff, position, roles: [...staff.roles], ends: endsOf(staff.roles, table) };
}
/** 测试账号策略(ADR-011):TEST 岗 + 独立工作组;不是测试账号就什么都不做 */
async function applyTestAccountPolicy(staff: Staff): Promise<void> {
if (!isTestAccount(staff.account, deps.testAccounts)) return;
const none = await positionByCode(NONE_POSITION_CODE);
if (staff.positionId === none.id) {
const test = await positionByCode(TEST_POSITION_CODE);
await deps.staff.setPosition(staff.id, test.id);
}
if ((await deps.workGroups.listByOwner(staff.id)).length === 0) {
await deps.workGroups.upsert(soloGroupOf(staff));
}
}
async function activeStaffRoles(): Promise<string[][]> {
return (await deps.staff.list()).filter((s) => s.state === 'active').map((s) => [...s.roles]);
}
async function insertPerson(
identity: Identity,
state: Staff['state'],
marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date },
): Promise<Staff> {
const none = await positionByCode(NONE_POSITION_CODE);
const created = await deps.staff.insert(
{
id: staffIdSchema.parse(identity.sub),
account: identity.account,
name: identity.name,
...(identity.email === undefined ? {} : { email: identity.email }),
positionId: none.id,
state,
roles: [...identity.roles],
},
marks,
);
await applyTestAccountPolicy(toStaff(created));
const fresh = await deps.staff.findById(created.id);
return toStaff(fresh ?? created);
}
const service: OrgService = {
async authenticate(token) {
const claims = await deps.verifier.verify(token);
await service.registerIdentity(identityOf(claims));
const id = staffIdSchema.parse(claims.sub);
const record = await deps.staff.findById(id);
if (record === undefined) throw new StaffNotFoundError(id);
return principalFor(record);
},
async principalOf(staffId) {
const record = await deps.staff.findById(staffId);
return record === undefined ? undefined : principalFor(record);
},
async registerIdentity(identity) {
const now = deps.clock.now();
const id = staffIdSchema.parse(identity.sub);
const existing = await deps.staff.findById(id);
if (existing === undefined) return insertPerson(identity, 'active', { lastSeenAt: now });
const changed = identityChanged(existing, identity);
const stale =
existing.lastSeenAt === undefined || now.getTime() - existing.lastSeenAt.getTime() >= LAST_SEEN_REWRITE_MS;
if (!changed && !stale) return toStaff(existing);
await deps.staff.updateIdentity(id, { ...patchOf(identity), lastSeenAt: now });
const fresh = await deps.staff.findById(id);
return toStaff(fresh ?? existing);
},
async listStaff() {
const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]);
const map = new Map(positions.map((p) => [p.id, p]));
const staff = await Promise.all(rows.map((s) => withPosition(s, map)));
const staff = await Promise.all(rows.map((s) => withPosition(toStaff(s), map)));
return { staff, positions };
},
async getStaff(staffId) {
const staff = await deps.staff.findById(staffId);
if (!staff) throw new StaffNotFoundError(staffId);
return withPosition(staff);
const record = await deps.staff.findById(staffId);
if (!record) throw new StaffNotFoundError(staffId);
return withPosition(toStaff(record));
},
async findByAccount(account) {
const staff = await deps.staff.findByAccount(account);
if (!staff) return undefined;
return withPosition(staff);
async staffOfPosition(positionCode) {
if (positionCode === NONE_POSITION_CODE) return [];
const position = await positionByCode(positionCode);
const rows = await deps.staff.listByPosition(position.id);
return rows.filter((s) => s.state === 'active').map((s) => ({ ...toStaff(s), position }));
},
async createStaff(input: CreateStaffInput) {
const taken = await deps.staff.findByAccount(input.account);
if (taken) throw new AccountTakenError(input.account);
const positionId = positionIdSchema.parse(input.positionId);
async setPosition(staffId, positionId) {
const record = await deps.staff.findById(staffId);
if (!record) throw new StaffNotFoundError(staffId);
const position = await deps.positions.findById(positionId);
if (!position) throw new PositionNotFoundError(input.positionId);
const created = await deps.staff.insert({
id: staffIdSchema.parse(deps.ids.next()),
account: input.account,
name: input.name,
positionId,
state: 'active',
roles: [],
});
// 新员工带初始口令建档 —— 不留「有账号但登不进」的半成品
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
return created;
if (!position) throw new PositionNotFoundError(positionId);
await deps.staff.setPosition(staffId, positionId);
return { ...toStaff(record), positionId, position };
},
async checkDisable(staffId) {
const staff = await deps.staff.findById(staffId);
if (!staff) throw new StaffNotFoundError(staffId);
async syncRoster(): Promise<RosterSyncOutcome> {
if (deps.roster === undefined) {
return {
configured: false,
message: '未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档',
};
}
const remote = await deps.roster.listUsers();
const local = await deps.staff.list();
const diff = diffRoster(remote, local);
const now = deps.clock.now();
for (const user of diff.insert) await insertPerson(user, user.enabled ? 'active' : 'disabled', { syncedAt: now });
for (const u of diff.update) await deps.staff.updateIdentity(u.id, u.patch);
for (const s of diff.setState) await deps.staff.setState(s.id, s.state, now);
return {
configured: true,
inserted: diff.insert.length,
updated: diff.update.length,
disabled: diff.setState.filter((s) => s.state === 'disabled').length,
total: remote.length,
};
},
async inFlightOf(staffId) {
const record = await deps.staff.findById(staffId);
if (!record) throw new StaffNotFoundError(staffId);
const [openTasks, openCards, openApprovals] = await Promise.all([
deps.inFlight.openTasksOf(staffId),
deps.inFlight.openCardsOf(staffId),
deps.inFlight.openApprovalsOf(staffId),
]);
return buildDisableCheck(staffId, { openTasks, openCards, openApprovals });
return buildInFlightWork(staffId, { openTasks, openCards, openApprovals });
},
async disableStaff(staffId) {
const check = await this.checkDisable(staffId);
if (check.blocked) throw new DisableBlockedError(check);
await deps.staff.setState(staffId, 'disabled');
// 停用即踢会话:否则被停用的人凭旧 cookie 还能继续办事(ADR-010)
await deps.sessions.revokeAllOf(staffId);
async listRoleAccess() {
const rows = await deps.roleAccess.list();
const seen = new Set<string>(rows.map((r) => r.role));
for (const s of await deps.staff.list()) for (const r of s.roles) seen.add(r);
return { roles: rows, knownRoles: [...seen].sort() };
},
async listGrants(positionId) {
return deps.grants.listByPosition(positionIdSchema.parse(positionId));
async setRoleAccess(role, ends) {
const row: RoleAccess = { role, ends: normalizeEnds(ends) };
const current = await deps.roleAccess.list();
assertNotLockedOut([...current.filter((r) => r.role !== role), row], await activeStaffRoles());
return deps.roleAccess.upsert(row);
},
async setGrants(positionId, grants) {
return deps.grants.replaceForPosition(positionIdSchema.parse(positionId), grants);
async deleteRoleAccess(role) {
const current = await deps.roleAccess.list();
assertNotLockedOut(
current.filter((r) => r.role !== role),
await activeStaffRoles(),
);
await deps.roleAccess.remove(role);
},
async authorize(staffId: StaffId, object: GrantObject, action: GrantAction) {
const staff = await deps.staff.findById(staffId);
if (!staff || staff.state !== 'active') return false;
const grants = await grantsOfStaff(staffId);
return isAuthorized(grants, object, action);
},
async loadSession(account): Promise<Session | undefined> {
const staff = await deps.staff.findByAccount(account);
if (!staff || staff.state !== 'active') return undefined;
const position = await deps.positions.findById(staff.positionId);
if (!position) throw new PositionNotFoundError(staff.positionId);
const grants = await deps.grants.listByPosition(staff.positionId);
return { staff, position, grants };
},
/**
* 登录 —— 失败一律 undefined,**不区分原因**:
* 账号不存在 / 已停用 / 未设口令 / 口令错,对外都是同一句话,不外泄账号是否存在。
*/
async login(account, password): Promise<IssuedSession | undefined> {
const staff = await deps.staff.findByAccount(account);
const stored = await deps.staff.passwordHashOf(account);
// 无论账号是否存在都走一遍散列比对:否则响应时间会泄漏账号是否存在
const ok = await deps.passwords.verify(password, stored);
if (!ok || staff === undefined || staff.state !== 'active') return undefined;
const token = deps.passwords.newToken();
const expiresAt = new Date(deps.clock.now().getTime() + SESSION_TTL_MS);
await deps.sessions.issue({ token, staffId: staff.id, expiresAt });
return { token, expiresAt: expiresAt.toISOString() };
},
async logout(token) {
await deps.sessions.revoke(token);
},
async resolveSession(token): Promise<Session | undefined> {
const now = deps.clock.now();
const hit = await deps.sessions.touch(token, now, new Date(now.getTime() + SESSION_TTL_MS));
if (hit === undefined) return undefined;
const staff = await deps.staff.findById(hit.staffId);
// 会话还在但人已停用 —— 立刻作废,不给旧 cookie 留缝
if (staff === undefined || staff.state !== 'active') {
await deps.sessions.revoke(token);
return undefined;
}
const position = await deps.positions.findById(staff.positionId);
if (!position) throw new PositionNotFoundError(staff.positionId);
const grants = await deps.grants.listByPosition(staff.positionId);
return { staff, position, grants };
},
async setPassword(staffId, password) {
const staff = await deps.staff.findById(staffId);
if (!staff) throw new StaffNotFoundError(staffId);
await deps.staff.setPasswordHash(staffId, await deps.passwords.hash(password));
async staffWithEnd(surface) {
const table = await deps.roleAccess.list();
const { staff } = await service.listStaff();
return staff.filter((s) => s.state === 'active' && endsOf(s.roles, table).includes(surface));
},
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */
async saveWorkGroup(input) {
const staff = await deps.staff.findById(input.ownerId);
if (!staff) throw new StaffNotFoundError(input.ownerId);
const record = await deps.staff.findById(input.ownerId);
if (!record) throw new StaffNotFoundError(input.ownerId);
return deps.workGroups.upsert({
id: input.id ?? deps.ids.next(),
name: input.name,
@@ -211,84 +263,31 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
},
async bindingOf(staffId) {
const group = await this.workGroupOf(staffId);
const group = await service.workGroupOf(staffId);
return group === undefined ? undefined : { ...group.members };
},
async staffOfPosition(positionCode) {
const position = await deps.positions.findByCode(positionCode);
if (!position) throw new PositionNotFoundError(positionCode);
const rows = await deps.staff.listByPosition(position.id);
return rows.filter((s) => s.state === 'active').map((s) => ({ ...s, position }));
},
async ensureSeeded() {
const byCode = new Map<string, PositionId>();
for (const seed of POSITION_SEED) {
const existing = await deps.positions.findByCode(seed.code);
const position = existing
? existing
: await deps.positions.upsert({
id: positionIdSchema.parse(deps.ids.next()),
code: seed.code,
name: seed.name,
note: seed.note,
});
byCode.set(seed.code, position.id);
}
for (const seed of STAFF_SEED) {
const existing = await deps.staff.findByAccount(seed.account);
if (existing) {
// 补种口令:ADR-010 之前建的员工没有 password_hash,不补就永远登不进
const hash = await deps.staff.passwordHashOf(seed.account);
if (hash === undefined) {
await deps.staff.setPasswordHash(existing.id, await deps.passwords.hash(deps.seedPassword));
}
continue;
}
const positionId = byCode.get(seed.position);
if (!positionId) throw new PositionNotFoundError(seed.position);
const created = await deps.staff.insert({
id: staffIdSchema.parse(deps.ids.next()),
account: seed.account,
if (existing !== undefined) continue;
await deps.positions.upsert({
id: positionIdSchema.parse(deps.ids.next()),
code: seed.code,
name: seed.name,
positionId,
state: 'active',
roles: [],
});
// 新员工一律带初始口令入库 —— 没有「无口令即可登录」的窗口
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
}
// 测试账号的独立工作组(ADR-011):所有业务岗都指向本人,起单即整条流归自己
for (const account of SOLO_GROUP_ACCOUNTS) {
const staff = await deps.staff.findByAccount(account);
if (staff === undefined) continue;
const existing = await deps.workGroups.listByOwner(staff.id);
if (existing.length > 0) continue;
await deps.workGroups.upsert({
id: `wg-solo-${account.toLowerCase()}`,
name: `${staff.name} 独立测试组`,
ownerId: staff.id,
members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])),
note: seed.note,
});
}
for (const [code, entries] of Object.entries(POSITION_GRANT_SEED)) {
const positionId = byCode.get(code);
if (!positionId) continue;
const existing = await deps.grants.listByPosition(positionId);
if (existing.length > 0) continue;
await deps.grants.replaceForPosition(
positionId,
entries.map((e) => ({
positionId,
object: { kind: 'governance' as const, function: governanceFunctionSchema.parse(e.fn) },
action: e.action,
})),
);
// 缺省的角色→端只在表空时播 —— 之后是运营中心的事,播种不覆盖人工改动
if ((await deps.roleAccess.list()).length === 0) {
for (const row of DEFAULT_ROLE_ACCESS) await deps.roleAccess.upsert(row);
}
// 已在册的测试账号补策略(名册先同步后配 JCAI_TEST_ACCOUNTS 的那种顺序)
for (const record of await deps.staff.list()) {
if (isTestAccount(record.account, deps.testAccounts)) await applyTestAccountPolicy(toStaff(record));
}
},
};
return service;
}
+48 -63
View File
@@ -2,73 +2,11 @@
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
* adapter 实现之,usecase 依赖之,app 装配之。
*/
import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
import type { Identity } from './contract.ts';
export type Clock = { now(): Date };
export type PositionRepository = {
list(): Promise<Position[]>;
findByCode(code: string): Promise<Position | undefined>;
findById(id: PositionId): Promise<Position | undefined>;
upsert(position: Omit<Position, 'id'> & { id?: PositionId }): Promise<Position>;
};
export type StaffRepository = {
list(): Promise<Staff[]>;
findById(id: StaffId): Promise<Staff | undefined>;
findByAccount(account: string): Promise<Staff | undefined>;
listByPosition(positionId: PositionId): Promise<Staff[]>;
insert(staff: Staff): Promise<Staff>;
setState(id: StaffId, state: Staff['state']): Promise<void>;
/** 口令散列的读写 —— 明文不经此端口(ADR-010) */
passwordHashOf(account: string): Promise<string | undefined>;
setPasswordHash(id: StaffId, hash: string): Promise<void>;
};
/**
* 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。
* token 即主键,不透明随机串,不编码任何身份信息。
*/
export type SessionRepository = {
issue(input: { token: string; staffId: StaffId; expiresAt: Date }): Promise<void>;
/** 未过期才返回;命中同时滑动续期 */
touch(token: string, now: Date, expiresAt: Date): Promise<{ staffId: StaffId } | undefined>;
revoke(token: string): Promise<void>;
revokeAllOf(staffId: StaffId): Promise<void>;
};
/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */
export type PasswordHasher = {
hash(password: string): Promise<string>;
verify(password: string, stored: string | undefined): Promise<boolean>;
newToken(): string;
};
export type GrantRepository = {
listByPosition(positionId: PositionId): Promise<Grant[]>;
replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise<Grant[]>;
};
/**
* 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`,
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
*/
export type InFlightWorkProbe = {
openTasksOf(staffId: StaffId): Promise<Array<{ taskId: string; title: string }>>;
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
openApprovalsOf(staffId: StaffId): Promise<Array<{ approvalId: string; title: string }>>;
};
/** 工作组仓储(ADR-011) */
export type WorkGroupRepository = {
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
upsert(group: WorkGroup): Promise<WorkGroup>;
};
/** ID 生成 —— domain 零随机(§5.3) */
export type IdGenerator = { next(): string };
/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */
/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */
@@ -95,3 +33,50 @@ export type IdentityPatch = {
readonly roles: readonly string[];
readonly lastSeenAt?: Date;
};
/* ── 仓储 ─────────────────────────────────────────────────────────────── */
export type PositionRepository = {
list(): Promise<Position[]>;
findByCode(code: string): Promise<Position | undefined>;
findById(id: PositionId): Promise<Position | undefined>;
upsert(position: Omit<Position, 'id'> & { id?: PositionId }): Promise<Position>;
};
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date };
export type StaffRepository = {
list(): Promise<StaffRecord[]>;
findById(id: StaffId): Promise<StaffRecord | undefined>;
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise<StaffRecord>;
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
};
export type RoleAccessRepository = {
list(): Promise<RoleAccess[]>;
upsert(row: RoleAccess): Promise<RoleAccess>;
remove(role: string): Promise<void>;
};
/**
* 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`,
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
*/
export type InFlightWorkProbe = {
openTasksOf(staffId: StaffId): Promise<Array<{ taskId: string; title: string }>>;
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
openApprovalsOf(staffId: StaffId): Promise<Array<{ approvalId: string; title: string }>>;
};
/** 工作组仓储(ADR-011) */
export type WorkGroupRepository = {
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
upsert(group: WorkGroup): Promise<WorkGroup>;
};
/** ID 生成 —— domain 零随机(§5.3) */
export type IdGenerator = { next(): string };