统一鉴权中心接入(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
+56 -69
View File
@@ -1,79 +1,16 @@
/**
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
*
* 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。
* 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。
* 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。
*/
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts';
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding };
import type { InFlightWork, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts';
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
export type StaffWithPosition = Staff & {
readonly position: Position;
};
export type CreateStaffInput = {
readonly account: string;
readonly name: string;
readonly positionId: string;
};
export declare class StaffNotFoundError extends Error {
readonly staffId: StaffId;
constructor(staffId: StaffId);
}
export declare class PositionNotFoundError extends Error {
readonly positionCode: string;
constructor(positionCode: string);
}
export declare class AccountTakenError extends Error {
readonly account: string;
constructor(account: string);
}
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
export declare class DisableBlockedError extends Error {
readonly check: DisableCheck;
constructor(check: DisableCheck);
}
/** 登录结果 —— 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>;
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 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>;
/** 建组 / 改组;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>;
};
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
export type Identity = {
readonly sub: string;
@@ -85,6 +22,14 @@ export type Identity = {
/** realm 角色,噪声角色已滤、已排序 */
readonly roles: readonly string[];
};
export declare class StaffNotFoundError extends Error {
readonly staffId: StaffId;
constructor(staffId: StaffId);
}
export declare class PositionNotFoundError extends Error {
readonly positionCode: string;
constructor(positionCode: string);
}
/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */
export declare class TokenRejectedError extends Error {
constructor(message: string);
@@ -98,3 +43,45 @@ export declare class AccountDisabledError extends Error {
export declare class AccessLockoutError extends Error {
constructor();
}
export type OrgService = {
/** 验签 → 名册即时更新 → 角色→端。抛 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[]>;
/** 建组 / 改组;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>;
};
+6 -12
View File
@@ -1,24 +1,18 @@
/**
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { CreateStaffInput, DisableCheck, Grant, GrantAction, GrantObject, IssuedSession, OrgService, Position, Session, Staff, StaffWithPosition, WorkGroup, WorkGroupBinding, } from './contract.ts';
export { AccountTakenError, DisableBlockedError, PositionNotFoundError, StaffNotFoundError, } from './contract.ts';
export type { Clock, GrantRepository, IdGenerator, InFlightWorkProbe, PasswordHasher, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository, } from './port.ts';
export type { Identity, InFlightWork, OrgService, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts';
export { AccessLockoutError, AccountDisabledError, PositionNotFoundError, StaffNotFoundError, TokenRejectedError, } from './contract.ts';
export type { Clock, IdGenerator, IdentityPatch, InFlightWorkProbe, PositionRepository, 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';
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';
+58 -74
View File
@@ -2,84 +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;
};
/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */
export type VerifiedClaims = {
readonly sub: string;
@@ -109,3 +36,60 @@ 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;
};