Archived
统一鉴权中心接入(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:
@@ -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_-]+$/);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user