From dd31b91829a3d41d2616ca029aa6df01a4bbebae Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 17:40:55 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=89=B4=E6=9D=83=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E6=8E=A5=E5=85=A5(ADR-012):org=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E4=B8=BA=20Keycloak=20=E8=BA=AB=E4=BB=BD=20+=20=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E2=86=92=E7=AB=AF;=E4=B8=AD=E9=97=B4=E4=BB=B6?= =?UTF-8?q?=E6=8C=89=E8=B7=AF=E7=94=B1=E5=BD=92=E5=B1=9E=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=88=A4;=E5=8F=A3=E4=BB=A4/=E4=BC=9A=E8=AF=9D/=E5=B2=97?= =?UTF-8?q?=E4=BD=8D=E6=8E=88=E6=9D=83=E9=80=80=E5=BD=B9;=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=200032?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw --- .../apps/api/src/modules/org/contract.d.ts | 125 +++--- .../apps/api/src/modules/org/index.d.ts | 18 +- api-report/apps/api/src/modules/org/port.d.ts | 132 +++--- apps/api/package.json | 1 - .../app/__tests__/new-package-flow.it.test.ts | 8 +- .../app/__tests__/testbed-trace.it.test.ts | 10 +- .../__tests__/unified-quote-flow.it.test.ts | 8 +- apps/api/src/app/config.ts | 55 ++- apps/api/src/app/container.ts | 84 +++- apps/api/src/app/http-env.ts | 20 +- apps/api/src/app/routes/desk-approval.ts | 5 +- apps/api/src/app/routes/desk-elements.ts | 17 +- apps/api/src/app/routes/desk.ts | 31 +- apps/api/src/app/routes/governance-ops.ts | 145 +------ apps/api/src/app/routes/governance.ts | 159 ++++--- apps/api/src/app/routes/registration.ts | 5 +- apps/api/src/app/routes/workbench.ts | 56 +-- apps/api/src/app/seed.ts | 4 +- apps/api/src/app/server.ts | 82 ++-- apps/api/src/app/session-cookie.ts | 39 -- apps/api/src/app/set-password.ts | 31 -- apps/api/src/app/wiring/engines.ts | 13 +- .../org/__tests__/authorization.test.ts | 92 ---- .../modules/org/__tests__/org-service.test.ts | 314 ++++++++++++++ .../modules/org/__tests__/password.test.ts | 56 --- apps/api/src/modules/org/contract.ts | 159 ++++--- apps/api/src/modules/org/index.ts | 56 ++- .../org/internal/adapter/org-repository.ts | 171 ++++---- .../org/internal/adapter/org-schema.ts | 52 +-- .../modules/org/internal/adapter/password.ts | 61 --- .../org/internal/domain/authorization.ts | 56 --- .../org/internal/domain/disable-check.ts | 25 -- .../modules/org/internal/domain/seed-data.ts | 89 +--- .../org/internal/usecase/org-service.ts | 393 +++++++++--------- apps/api/src/modules/org/port.ts | 111 +++-- apps/api/tsup.config.ts | 2 - db/migrations/0032_keycloak_identity.sql | 27 ++ db/migrations/meta/_journal.json | 9 +- 38 files changed, 1236 insertions(+), 1485 deletions(-) delete mode 100644 apps/api/src/app/session-cookie.ts delete mode 100644 apps/api/src/app/set-password.ts delete mode 100644 apps/api/src/modules/org/__tests__/authorization.test.ts create mode 100644 apps/api/src/modules/org/__tests__/org-service.test.ts delete mode 100644 apps/api/src/modules/org/__tests__/password.test.ts delete mode 100644 apps/api/src/modules/org/internal/adapter/password.ts delete mode 100644 apps/api/src/modules/org/internal/domain/authorization.ts delete mode 100644 apps/api/src/modules/org/internal/domain/disable-check.ts create mode 100644 db/migrations/0032_keycloak_identity.sql diff --git a/api-report/apps/api/src/modules/org/contract.d.ts b/api-report/apps/api/src/modules/org/contract.d.ts index f419257..468f225 100644 --- a/api-report/apps/api/src/modules/org/contract.d.ts +++ b/api-report/apps/api/src/modules/org/contract.d.ts @@ -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; - findByAccount(account: string): Promise; - createStaff(input: CreateStaffInput): Promise; - /** 停用前置检查 —— 只读,治理台先看再点 */ - checkDisable(staffId: StaffId): Promise; - /** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */ - disableStaff(staffId: StaffId): Promise; - listGrants(positionId: string): Promise; - setGrants(positionId: string, grants: readonly Grant[]): Promise; - /** 岗位权限校验 —— app 层鉴权中间件读它 */ - authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise; - loadSession(account: string): Promise; - /** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */ - login(account: string, password: string): Promise; - /** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */ - logout(token: string): Promise; - /** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */ - resolveSession(token: string): Promise; - /** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */ - setPassword(staffId: StaffId, password: string): Promise; - /** 建组 / 改组;id 留空即新建 */ - saveWorkGroup(input: { - id?: string; - name: string; - ownerId: StaffId; - members: Readonly>; - }): Promise; - /** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */ - workGroupOf(staffId: StaffId): Promise; - /** 装进工作流的快照:改组不改已起的单 */ - bindingOf(staffId: StaffId): Promise; - /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */ - staffOfPosition(positionCode: string): Promise; - ensureSeeded(): Promise; -}; /** 从 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; + /** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */ + principalOf(staffId: StaffId): Promise; + /** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */ + registerIdentity(identity: Identity): Promise; + listStaff(): Promise<{ + staff: StaffWithPosition[]; + positions: Position[]; + }>; + getStaff(staffId: StaffId): Promise; + /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */ + staffOfPosition(positionCode: string): Promise; + setPosition(staffId: StaffId, positionId: PositionId): Promise; + /** Admin API 同步;未配 provisioning 回 configured:false */ + syncRoster(): Promise; + /** 此人身上还挂着什么(只读;OS 02 改派先于停用) */ + inFlightOf(staffId: StaffId): Promise; + listRoleAccess(): Promise<{ + roles: RoleAccess[]; + knownRoles: string[]; + }>; + /** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */ + setRoleAccess(role: string, ends: readonly Surface[]): Promise; + deleteRoleAccess(role: string): Promise; + /** 能进该端的活跃员工 —— 治理台通知的收件人 */ + staffWithEnd(surface: Surface): Promise; + /** 建组 / 改组;id 留空即新建 */ + saveWorkGroup(input: { + id?: string; + name: string; + ownerId: StaffId; + members: Readonly>; + }): Promise; + /** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */ + workGroupOf(staffId: StaffId): Promise; + /** 装进工作流的快照:改组不改已起的单 */ + bindingOf(staffId: StaffId): Promise; + /** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */ + ensureSeeded(): Promise; +}; diff --git a/api-report/apps/api/src/modules/org/index.d.ts b/api-report/apps/api/src/modules/org/index.d.ts index 5cb9ad7..2a126d4 100644 --- a/api-report/apps/api/src/modules/org/index.d.ts +++ b/api-report/apps/api/src/modules/org/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/org/port.d.ts b/api-report/apps/api/src/modules/org/port.d.ts index 3f0baaf..3172508 100644 --- a/api-report/apps/api/src/modules/org/port.d.ts +++ b/api-report/apps/api/src/modules/org/port.d.ts @@ -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; - findByCode(code: string): Promise; - findById(id: PositionId): Promise; - upsert(position: Omit & { - id?: PositionId; - }): Promise; -}; -export type StaffRepository = { - list(): Promise; - findById(id: StaffId): Promise; - findByAccount(account: string): Promise; - listByPosition(positionId: PositionId): Promise; - insert(staff: Staff): Promise; - setState(id: StaffId, state: Staff['state']): Promise; - /** 口令散列的读写 —— 明文不经此端口(ADR-010) */ - passwordHashOf(account: string): Promise; - setPasswordHash(id: StaffId, hash: string): Promise; -}; -/** - * 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。 - * token 即主键,不透明随机串,不编码任何身份信息。 - */ -export type SessionRepository = { - issue(input: { - token: string; - staffId: StaffId; - expiresAt: Date; - }): Promise; - /** 未过期才返回;命中同时滑动续期 */ - touch(token: string, now: Date, expiresAt: Date): Promise<{ - staffId: StaffId; - } | undefined>; - revoke(token: string): Promise; - revokeAllOf(staffId: StaffId): Promise; -}; -/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */ -export type PasswordHasher = { - hash(password: string): Promise; - verify(password: string, stored: string | undefined): Promise; - newToken(): string; -}; -export type GrantRepository = { - listByPosition(positionId: PositionId): Promise; - replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise; -}; -/** - * 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`, - * 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。 - */ -export type InFlightWorkProbe = { - openTasksOf(staffId: StaffId): Promise>; - openCardsOf(staffId: StaffId): Promise>; - openApprovalsOf(staffId: StaffId): Promise>; -}; -/** 工作组仓储(ADR-011) */ -export type WorkGroupRepository = { - listByOwner(ownerId: StaffId): Promise; - upsert(group: WorkGroup): Promise; -}; -/** 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; + findByCode(code: string): Promise; + findById(id: PositionId): Promise; + upsert(position: Omit & { + id?: PositionId; + }): Promise; +}; +/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ +export type StaffRecord = Staff & { + readonly lastSeenAt?: Date; + readonly syncedAt?: Date; +}; +export type StaffRepository = { + list(): Promise; + findById(id: StaffId): Promise; + listByPosition(positionId: PositionId): Promise; + insert(staff: Staff, marks: { + readonly lastSeenAt?: Date; + readonly syncedAt?: Date; + }): Promise; + updateIdentity(id: StaffId, patch: IdentityPatch): Promise; + setPosition(id: StaffId, positionId: PositionId): Promise; + setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; +}; +export type RoleAccessRepository = { + list(): Promise; + upsert(row: RoleAccess): Promise; + remove(role: string): Promise; +}; +/** + * 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`, + * 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。 + */ +export type InFlightWorkProbe = { + openTasksOf(staffId: StaffId): Promise>; + openCardsOf(staffId: StaffId): Promise>; + openApprovalsOf(staffId: StaffId): Promise>; +}; +/** 工作组仓储(ADR-011) */ +export type WorkGroupRepository = { + listByOwner(ownerId: StaffId): Promise; + upsert(group: WorkGroup): Promise; +}; +/** ID 生成 —— domain 零随机(§5.3) */ +export type IdGenerator = { + next(): string; +}; diff --git a/apps/api/package.json b/apps/api/package.json index 58571f6..7dc86a8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,6 @@ "start": "node dist/main.js", "seed": "node dist/seed.js", "migrate": "node dist/migrate.js", - "set-password": "node dist/set-password.js", "reset-runs": "node dist/reset-runs.js", "install-activities": "node dist/install-activities.js" }, diff --git a/apps/api/src/app/__tests__/new-package-flow.it.test.ts b/apps/api/src/app/__tests__/new-package-flow.it.test.ts index 9b58b87..2877922 100644 --- a/apps/api/src/app/__tests__/new-package-flow.it.test.ts +++ b/apps/api/src/app/__tests__/new-package-flow.it.test.ts @@ -80,12 +80,14 @@ beforeAll(async () => { await pool.query(`create schema "${schema}"`); } await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); - container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); + container = await createContainer( + loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), + ); await container.org.ensureSeeded(); // 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行 await seedMvp(container); - const { staff } = await container.org.listStaff(); - chaihao = staff.find((s) => s.account === 'Chaihao')!.id; + // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) + chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id; }, 180_000); afterAll(async () => { diff --git a/apps/api/src/app/__tests__/testbed-trace.it.test.ts b/apps/api/src/app/__tests__/testbed-trace.it.test.ts index e3d6185..5f86e3b 100644 --- a/apps/api/src/app/__tests__/testbed-trace.it.test.ts +++ b/apps/api/src/app/__tests__/testbed-trace.it.test.ts @@ -77,12 +77,14 @@ beforeAll(async () => { await pool.query(`create schema "${schema}"`); } await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); - container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); + container = await createContainer( + loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), + ); await container.org.ensureSeeded(); await seedMvp(container); - const { staff } = await container.org.listStaff(); - chaihao = staff.find((s) => s.account === 'Chaihao')!.id; - ai = staff.find((s) => s.account === 'AI')!.id; + // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) + chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id; + ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id; }, 180_000); afterAll(async () => { diff --git a/apps/api/src/app/__tests__/unified-quote-flow.it.test.ts b/apps/api/src/app/__tests__/unified-quote-flow.it.test.ts index 0cad965..d4edbf4 100644 --- a/apps/api/src/app/__tests__/unified-quote-flow.it.test.ts +++ b/apps/api/src/app/__tests__/unified-quote-flow.it.test.ts @@ -88,12 +88,14 @@ beforeAll(async () => { await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); // 配置指向测试库 —— 经 loadConfig 的 env 形参,不污染 process.env - container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); + container = await createContainer( + loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), + ); await container.org.ensureSeeded(); await seedMvp(container); - const { staff } = await container.org.listStaff(); - chaihao = staff.find((s) => s.account === 'Chaihao')!.id; + // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) + chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id; }, 180_000); afterAll(async () => { diff --git a/apps/api/src/app/config.ts b/apps/api/src/app/config.ts index a1a7ba8..5eff790 100644 --- a/apps/api/src/app/config.ts +++ b/apps/api/src/app/config.ts @@ -3,6 +3,18 @@ */ import { join } from 'node:path'; +/** 统一鉴权中心(ADR-012)。本服务只验不调,**不配 client secret**(手册 13.2) */ +export type KeycloakConfig = { + readonly url: string; + readonly realm: string; + /** 校 aud 用 —— 填错全线 401 */ + readonly clientId: string; + readonly issuer: string; + readonly jwksUrl: string; + /** master realm 的 provisioning 服务账号(只读名册,view-users);可选 */ + readonly provisioning?: { readonly clientId: string; readonly secret: string }; +}; + export type AppConfig = { readonly nodeEnv: 'development' | 'test' | 'production'; readonly port: number; @@ -11,25 +23,52 @@ export type AppConfig = { readonly attachmentRoot: string; /** 派发器轮询间隔(毫秒) */ readonly dispatchIntervalMs: number; - /** - * 播种口令(ADR-010)—— 新建员工的初始口令。 - * 源码不写任何真实口令;生产部署必须经环境变量覆盖缺省值。 - */ - readonly seedPassword: string; + /** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */ + readonly keycloak?: KeycloakConfig; + /** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */ + readonly testAccounts: readonly string[]; }; const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos'; -/** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */ -const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026'; + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed === '' ? undefined : trimmed; +} + +function keycloakOf(env: NodeJS.ProcessEnv): KeycloakConfig | undefined { + const url = nonEmpty(env['KEYCLOAK_URL'])?.replace(/\/+$/, ''); + const realm = nonEmpty(env['KEYCLOAK_REALM']); + const clientId = nonEmpty(env['KEYCLOAK_CLIENT_ID']); + if (url === undefined || realm === undefined || clientId === undefined) return undefined; + const issuer = `${url}/realms/${realm}`; + const provisioningId = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_ID']); + const provisioningSecret = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_SECRET']); + return { + url, + realm, + clientId, + issuer, + jwksUrl: `${issuer}/protocol/openid-connect/certs`, + ...(provisioningId === undefined || provisioningSecret === undefined + ? {} + : { provisioning: { clientId: provisioningId, secret: provisioningSecret } }), + }; +} export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const nodeEnv = env['NODE_ENV']; + const keycloak = keycloakOf(env); return { nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development', port: Number(env['PORT'] ?? 8787), databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL, attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'), dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000), - seedPassword: env['JCAI_SEED_PASSWORD'] ?? DEFAULT_SEED_PASSWORD, + ...(keycloak === undefined ? {} : { keycloak }), + testAccounts: (env['JCAI_TEST_ACCOUNTS'] ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''), }; } diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 6a14e2b..f9df2bc 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -16,18 +16,20 @@ import { createEventService, createEventStore } from '@modules/event'; import type { LedgerService } from '@modules/ledger'; import type { LoopEngineService } from '@modules/loop-engine'; import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger'; -import type { OrgService, InFlightWorkProbe } from '@modules/org'; +import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org'; import { - createGrantRepository, + createKeycloakRoster, + createKeycloakTokenVerifier, createOrgService, createPositionRepository, - createSessionRepository, + createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, - hashPassword, - newSessionToken, - verifyPassword, + TokenRejectedError, } from '@modules/org'; +import { createRemoteJWKSet } from 'jose'; +import { createTicketIssuer } from './ticket.ts'; +import type { TicketIssuer } from './ticket.ts'; import type { LlmService, ModelGateway } from '@modules/llm'; import { createLlmInstanceRepository, @@ -94,6 +96,8 @@ export type Container = { readonly pool: Pool; readonly db: Db; readonly org: OrgService; + /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 下载这两条带不了 Authorization 头的路 */ + readonly tickets: TicketIssuer; readonly ledger: LedgerService; readonly event: EventService; readonly llm: LlmService; @@ -171,7 +175,13 @@ const sfeelEvaluator = (expression: string, scope: Readonly(FIRST_PARTY_TOOLS); -export async function createContainer(config: AppConfig): Promise { +/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */ +export type ContainerOverrides = { + readonly tokenVerifier?: TokenVerifier; + readonly roster?: RosterSource; +}; + +export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise { /* * 广播器**先建** —— 装配期就要往它上面接线。 * @@ -197,25 +207,60 @@ export async function createContainer(config: AppConfig): Promise { */ const bridge = bridgeBroadcaster(pool, broadcaster); const ids = createIdGenerator(); - const secrets = createSecretBox(loadSecretKey()); + const masterKey = loadSecretKey(); + const secrets = createSecretBox(masterKey); + const tickets = createTicketIssuer(masterKey); // 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出 await pool.query(DRAFT_DDL); const inFlightProbe = createDeferredInFlightProbe(); + /* + * 身份归统一鉴权中心(ADR-012):验签走 jose 远程 JWKS(首次用到才拉、缓存 10 分钟、 + * 30 秒冷却、5 秒超时 —— 手册 4.1);没配 KEYCLOAK_* 就装一个恒拒的 verifier: + * 集成测试走用例不走 HTTP,不受影响;真跑起来缺配置的话 main.ts 早就拒绝启动了。 + */ + const keycloak = config.keycloak; + const verifier: TokenVerifier = + overrides.tokenVerifier ?? + (keycloak === undefined + ? { + verify: async () => { + throw new TokenRejectedError('未配置 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID'); + }, + } + : createKeycloakTokenVerifier({ + issuer: keycloak.issuer, + audience: keycloak.clientId, + getKey: createRemoteJWKSet(new URL(keycloak.jwksUrl), { + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + timeoutDuration: 5_000, + }), + })); + const roster: RosterSource | undefined = + overrides.roster ?? + (keycloak?.provisioning === undefined + ? undefined + : createKeycloakRoster({ + url: keycloak.url, + realm: keycloak.realm, + clientId: keycloak.provisioning.clientId, + secret: keycloak.provisioning.secret, + })); + const org = createOrgService({ + verifier, + ...(roster === undefined ? {} : { roster }), positions: createPositionRepository(db), staff: createStaffRepository(db), - grants: createGrantRepository(db), - sessions: createSessionRepository(db), + roleAccess: createRoleAccessRepository(db), workGroups: createWorkGroupRepository(db), - // 散列算法在 adapter,usecase 只认这三个动作(ADR-010) - passwords: { hash: hashPassword, verify: verifyPassword, newToken: newSessionToken }, inFlight: inFlightProbe, clock: systemClock, - seedPassword: config.seedPassword, ids, + testAccounts: config.testAccounts, }); // message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入 @@ -226,16 +271,8 @@ export async function createContainer(config: AppConfig): Promise { const { staff } = await org.listStaff(); return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id })); }, - governanceStaff: async () => { - const { staff } = await org.listStaff(); - const governing = await Promise.all( - staff.map(async (s) => ({ - s, - ok: await org.authorize(s.id, { kind: 'governance', function: 'task' }, 'govern'), - })), - ); - return governing.filter((g) => g.ok).map((g) => ({ id: g.s.id })); - }, + // 治理台收件人 = 能进运营中心的活跃员工(任务在那儿管;ADR-012 权限只到端) + governanceStaff: async () => (await org.staffWithEnd('ops')).map((s) => ({ id: s.id })), }, clock: systemClock, ids, @@ -715,6 +752,7 @@ export async function createContainer(config: AppConfig): Promise { pool, db, org, + tickets, ledger, event, llm, diff --git a/apps/api/src/app/http-env.ts b/apps/api/src/app/http-env.ts index c8b814a..b118114 100644 --- a/apps/api/src/app/http-env.ts +++ b/apps/api/src/app/http-env.ts @@ -1,12 +1,20 @@ /** * Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。 */ -import type { Principal, Session } from '@contracts'; +import type { Context } from 'hono'; +import type { Position, Principal, Staff } from '@contracts'; /** - * 请求上下文。`principal` 由鉴权中间件从 Bearer / 票据解出(ADR-012); - * `account` / `session` 是 ADR-010 的旧物,随 Task 8 一并删除。 + * 请求上下文(ADR-012)。`principal` 由鉴权中间件从 Bearer / 票据解出 —— + * 身份只能来自验过签的 token,不再有「cookie 里写谁就是谁」。 */ -export type AppEnv = { - Variables: { account: string; session: Session | undefined; principal: Principal | undefined }; -}; +export type AppEnv = { Variables: { principal: Principal | undefined } }; + +/** + * 当前登录者(连同岗位)。非公开路由上中间件已保证存在;公开路由上是 undefined —— + * 调用方照旧判一下 undefined,免得哪天有人把一条路由改成 public 还在读它。 + */ +export function staffOf(c: Context): (Staff & { readonly position: Position }) | undefined { + const principal = c.get('principal'); + return principal === undefined ? undefined : { ...principal.staff, position: principal.position }; +} diff --git a/apps/api/src/app/routes/desk-approval.ts b/apps/api/src/app/routes/desk-approval.ts index c9cddba..fe6e465 100644 --- a/apps/api/src/app/routes/desk-approval.ts +++ b/apps/api/src/app/routes/desk-approval.ts @@ -9,6 +9,7 @@ import type { Hono } from 'hono'; import type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts'; import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts'; import type { Container } from '../container.ts'; +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; /** 驳回走向的人读说明 —— 声明式,写在流程包里,不由临场决定 */ @@ -23,7 +24,7 @@ export function registerDeskApprovalRoutes(app: Hono, container: Contain /** 我岗位上待批的审批点 —— 审批人由节点声明的岗位解析而来 */ app.get(listApprovalsForMeRoute.path, async (c) => { - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); if (me === undefined) return c.json(unauthenticated, 401); const workflows = await container.process.listWorkflows(50); @@ -72,7 +73,7 @@ export function registerDeskApprovalRoutes(app: Hono, container: Contain }); app.post(decideApprovalRoute.path, async (c) => { - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); if (me === undefined) return c.json(unauthenticated, 401); const parsed = decideApprovalRoute.body.safeParse(await c.req.json()); if (!parsed.success) { diff --git a/apps/api/src/app/routes/desk-elements.ts b/apps/api/src/app/routes/desk-elements.ts index de2ae34..ba23011 100644 --- a/apps/api/src/app/routes/desk-elements.ts +++ b/apps/api/src/app/routes/desk-elements.ts @@ -23,13 +23,10 @@ import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/ import type { Report } from '@modules/report'; import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report'; import type { Container } from '../container.ts'; +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; export function registerDeskElementRoutes(app: Hono, container: Container): void { - async function me(account: string) { - return container.org.findByAccount(account); - } - const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' }; /** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */ @@ -85,7 +82,7 @@ export function registerDeskElementRoutes(app: Hono, container: Containe }); app.post(createMinuteRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = createMinuteRoute.body.safeParse(await c.req.json()); if (!parsed.success) { @@ -104,7 +101,7 @@ export function registerDeskElementRoutes(app: Hono, container: Containe }); app.post(appendResolutionRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = appendResolutionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { @@ -129,7 +126,7 @@ export function registerDeskElementRoutes(app: Hono, container: Containe /** 决议 → 登记起单:登记人即来源(OS 3.1) */ app.post(registerResolutionRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = registerResolutionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { @@ -167,14 +164,14 @@ export function registerDeskElementRoutes(app: Hono, container: Containe /* ── 汇报 ─────────────────────────────────────────────────────────── */ app.get(listReportsRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const reports = await container.report.list(staff.id); return c.json({ reports: reports.map(toReportView) }); }); app.get(draftReportRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const period = reportPeriodSchema.safeParse(c.req.param('period')); if (!period.success) { @@ -186,7 +183,7 @@ export function registerDeskElementRoutes(app: Hono, container: Containe }); app.post(submitReportRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({}))); try { diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index abec9c1..047b264 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -41,6 +41,7 @@ function rowsOf(result: unknown): T[] { const rows = (result as { rows?: unknown }).rows; return Array.isArray(rows) ? (rows as T[]) : []; } +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts'; import { orderChat } from '../chat-order.ts'; @@ -846,12 +847,6 @@ export function gateOf( } export function registerDeskRoutes(app: Hono, container: Container): void { - /** 当前登录者;缺会话即 401 */ - async function me(account: string) { - const staff = await container.org.findByAccount(account); - return staff; - } - /** 任务卡 —— 一条 = 一个活动的一次运行;序位取自工作流的节点实例 */ async function toCard(task: Awaited>): Promise { let position = 1; @@ -949,7 +944,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi } app.get(listTasksRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -960,7 +955,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.get(taskDetailRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1192,7 +1187,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi * 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。 */ app.get(openAssetFileRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1298,7 +1293,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.post(saveTableRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1337,7 +1332,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi /** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */ app.post(replyWorkOrderRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1431,7 +1426,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi * 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。 */ app.post(replyChatCardRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1556,7 +1551,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi * 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。 */ app.post(markCommReadRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1615,7 +1610,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.post(sayRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1630,7 +1625,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.get(listMessagesRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1686,7 +1681,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi /** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */ app.post(confirmTriggerPendingRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1710,7 +1705,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.post(markMessageReadRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1725,7 +1720,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi * 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。 */ app.post(answerMessageRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 62adb4f..0e73a47 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -7,7 +7,7 @@ import { sql } from 'drizzle-orm'; import { extractText } from '../upload-extract.ts'; import type { Hono } from 'hono'; -import type { ApiError, GovernanceFunction, GrantAction } from '@contracts'; +import type { ApiError } from '@contracts'; import { browseLedgerRoute, controlTaskRoute, @@ -71,6 +71,7 @@ import { VersionTakenError, } from '@modules/process'; import type { Container } from '../container.ts'; +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; import { displayLog } from './log-lines.ts'; @@ -366,46 +367,9 @@ async function assembleFromMail( } export function registerGovernanceOpsRoutes(app: Hono, container: Container): void { - /** - * 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。 - * - * 客体三类 × 动作三种的分法就是为此:BD 有 `task:read` 能看在途,却动不了控制事件; - * PM 有 `task:govern`,读写皆通(动作蕴含 govern ⊃ write ⊃ read)。 - * 一律要 govern 会把只读监控也锁死,等于把三种动作压成一种。 - */ - function guard(fn: GovernanceFunction, action: GrantAction) { - return async (account: string): Promise => { - const staff = await container.org.findByAccount(account); - if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' }; - const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, action); - if (ok) return undefined; - const need = action === 'govern' ? '治理权' : '查看权'; - return { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」${need}` }; - }; - } - - const readTask = guard('task', 'read'); - const governTask = guard('task', 'govern'); - const readProcess = guard('process', 'read'); - const governProcess = guard('process', 'govern'); - const readTrigger = guard('trigger', 'read'); - const governTrigger = guard('trigger', 'govern'); - const readActivity = guard('activity', 'read'); - const governActivity = guard('activity', 'govern'); - const readTool = guard('tool', 'read'); - const governTool = guard('tool', 'govern'); - const readData = guard('data', 'read'); - const readMemory = guard('memory', 'read'); - - /** 拒绝的 HTTP 语义:无权 403,无会话 401 */ - const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401); - /* ── 任务管理:在途监控 · 控制 · 改派 · 任务日志 ──────────────────── */ app.get(listGovTasksRoute.path, async (c) => { - const denied = await readTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const { staff } = await container.org.listStaff(); const nameOf = new Map(staff.map((s) => [s.id, s.name])); const rows = await Promise.all( @@ -438,9 +402,7 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 控制三语义 —— 叫停留痕为最重档纠正信号 */ app.post(controlTaskRoute.path, async (c) => { - const denied = await governTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const staff = await container.org.findByAccount(c.get('account')); + const staff = staffOf(c); const parsed = controlTaskRoute.body.safeParse(await c.req.json()); if (!parsed.success || staff === undefined) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -456,18 +418,14 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.post(resumeTaskRoute.path, async (c) => { - const denied = await governTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const staff = await container.org.findByAccount(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401); const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id); return c.json({ ok: true, runtime: task.runtime }); }); app.post(reassignTaskRoute.path, async (c) => { - const denied = await governTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const staff = await container.org.findByAccount(c.get('account')); + const staff = staffOf(c); const parsed = reassignTaskRoute.body.safeParse(await c.req.json()); if (!parsed.success || staff === undefined) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -482,8 +440,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(govTaskLogRoute.path, async (c) => { - const denied = await readTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId'))); return c.json({ // 与工作台同一份日志、同一套口径:章是四字动作,内容带主语 @@ -502,9 +458,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。 */ app.get(listApprovalsRoute.path, async (c) => { - const denied = await readTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const workflows = await container.process.listWorkflows(50); const perWorkflow = await Promise.all( workflows.map(async (w) => { @@ -551,14 +504,12 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。 */ app.post(testRunRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = testRunRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues }; return c.json(body, 400); } - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); const { packageId, mail, assignments } = parsed.data; try { const data = await assembleFromMail(container, packageId, mail); @@ -592,8 +543,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 仿真不是生产:试跑留下的任务不该在别人工作台上过夜,也不该混进在途口径。 */ app.post(testStopRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = testStopRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' }; @@ -620,8 +569,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */ app.get(testCurrentRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); /* * **在跑与跑完要分得开**(`live`)。 * @@ -668,14 +615,12 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * `origin='testbed'`,好让人随时连根拔掉。 */ app.post(testRunActivityRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = testRunActivityRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues }; return c.json(body, 400); } - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); const { activityId, data, message, ownerId } = parsed.data; /* * 派给谁:显式指定优先,否则**派给操作者自己**。 @@ -729,8 +674,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。 */ app.get(testActivityStateRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const taskId = taskIdSchema.parse(c.req.param('taskId')); /* * **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在 @@ -776,16 +719,12 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); }); - - /* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */ /** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */ const UPLOAD_CAP = 8 * 1024 * 1024; app.post(uploadFileRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = uploadFileRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues }; @@ -806,7 +745,7 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai } // 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note const got = extractText(name, contentType, bytes); - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); const ref = crypto.randomUUID(); await container.db.execute(sql` insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by) @@ -823,8 +762,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(downloadFileRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const rows = await container.db.execute<{ name: string; content_type: string; bytes: Buffer }>( sql`select name, content_type, bytes from sys.upload where id = ${c.req.param('id')}`, ); @@ -844,8 +781,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */ app.get(listTestLogsRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const rows = await container.db.execute(sql` select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, jsonb_array_length(trail) as trail_count, @@ -857,8 +792,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(testLogDetailRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const rows = await container.db.execute(sql` select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, jsonb_array_length(trail) as trail_count, @@ -875,8 +808,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.delete(deleteTestLogRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const rows = await container.db.execute<{ id: string }>( sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`, ); @@ -884,25 +815,18 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.delete(clearTestLogsRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const rows = await container.db.execute<{ id: string }>(sql`delete from sys.testbed_run returning id`); return c.json({ deleted: rowsOf<{ id: string }>(rows).length }); }); /** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */ app.get(traceOfWorkflowRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const spans = await container.trace.ofWorkflow(c.req.param('id')); return c.json({ spans }); }); /** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */ app.get(govWorkflowRoute.path, async (c) => { - const denied = await readTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const workflow = await container.process.getWorkflow(c.req.param('id')); const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion); if (pkg === undefined) { @@ -945,8 +869,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 工作流管理 ──────────────────────────────────────────────────── */ app.get(listProcessPackagesRoute.path, async (c) => { - const denied = await readProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); return c.json({ packages: await container.process.listPackages() }); }); @@ -955,8 +877,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。 */ app.put(saveProcessRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = saveProcessRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues }; @@ -986,8 +906,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */ /** 删一版:生效版先停用(409),在途钉版不许删(409)—— 那些单要按它跑完 */ app.delete(deleteProcessVersionRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); try { const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version'))); return c.json({ packages }); @@ -1004,14 +922,10 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 审批模板(审批活动)—— 活动库里配、编排里引 ─────────────── */ app.get(listApprovalTemplatesRoute.path, async (c) => { - const denied = await readProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); return c.json({ templates: await container.process.approvalTemplates() }); }); app.put(upsertApprovalTemplateRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = upsertApprovalTemplateRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '审批模板不合协议(标识与名称必填)' }; @@ -1021,8 +935,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.delete(deleteApprovalTemplateRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); try { await container.process.deleteApprovalTemplate(c.req.param('id')); return c.json({ ok: true }); @@ -1042,8 +954,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 反查放在路由层而不是用例层:process 不该知道 trigger 的存在(L7 不依赖它)。 */ app.delete(deleteProcessFlowRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const id = c.req.param('id'); const rules = (await container.trigger.list()).filter((t) => t.processPackageId === id); if (rules.length > 0) { @@ -1067,8 +977,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */ app.post(editProcessVersionRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = editProcessVersionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' }; @@ -1096,8 +1004,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.post(setProcessStateRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = setProcessStateRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' }; @@ -1122,8 +1028,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 画图即校验 —— 与前端画布同源 */ app.post(validateProcessRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = validateProcessRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues }; @@ -1135,14 +1039,10 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 触发器配置 ──────────────────────────────────────────────────── */ app.get(listTriggersRoute.path, async (c) => { - const denied = await readTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); return c.json({ triggers: await container.trigger.list() }); }); app.post(toggleTriggerRoute.path, async (c) => { - const denied = await governTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = toggleTriggerRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -1158,8 +1058,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 设计动规则不碰这两项,运营动这两项不碰规则。 */ app.post(setTriggerConfirmRoute.path, async (c) => { - const denied = await governTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = setTriggerConfirmRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -1178,15 +1076,11 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 节点负责人(运营配置):显式指派 > 这里 > 轮值 */ app.get(listNodeOwnersRoute.path, async (c) => { - const denied = await readProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const owners = await container.process.nodeOwners(c.req.param('id')); return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) }); }); app.put(setNodeOwnerRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = setNodeOwnerRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -1201,8 +1095,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。 */ app.put(upsertTriggerRoute.path, async (c) => { - const denied = await governTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = upsertTriggerRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues }; @@ -1219,8 +1111,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。 */ app.post(installActivityRoute.path, async (c) => { - const denied = await governActivity(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = installActivityRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues }; @@ -1259,8 +1149,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(listActivitiesRoute.path, async (c) => { - const denied = await readActivity(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const cards = await container.registry.list(); /* * 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里: @@ -1374,8 +1262,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。 */ app.post(registerToolRoute.path, async (c) => { - const denied = await governTool(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = registerToolRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues }; @@ -1397,8 +1283,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(listToolsRoute.path, async (c) => { - const denied = await readTool(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const tools = await container.toolbox.list(); return c.json({ tools: tools.map((t) => ({ @@ -1417,8 +1301,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */ app.get(listLedgerObjectsRoute.path, async (c) => { - const denied = await readData(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); /* * 性质随 `listObjects` **一次查询带回**(0026_object_meta)—— * 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *, @@ -1438,9 +1320,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。 */ app.get(ledgerByActivityRoute.path, async (c) => { - const denied = await readData(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const [cards, materialized] = await Promise.all([ container.registry.list(), container.ledger.listObjects(), @@ -1557,8 +1436,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(browseLedgerRoute.path, async (c) => { - const denied = await readData(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const address = { domain: c.req.param('domain'), object: c.req.param('object') }; const described = await container.ledger.describe(address); if (described === undefined) { @@ -1595,8 +1472,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。 */ app.get(govMetricsRoute.path, async (c) => { - const denied = await readMemory(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = govMetricsRoute.query.safeParse(c.req.query()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '统计窗口不合法', detail: parsed.error.issues }; @@ -1612,8 +1487,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 系统管理 · 通道实例 ────────────────────────────────────────── */ app.get(listChannelsRoute.path, async (c) => { - const denied = await readTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const channels = await container.channel.list(); return c.json({ channels: channels.map((ch) => ({ @@ -1632,8 +1505,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(listActivityMemoryRoute.path, async (c) => { - const denied = await readMemory(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 }); return c.json({ entries: entries.map((e) => ({ diff --git a/apps/api/src/app/routes/governance.ts b/apps/api/src/app/routes/governance.ts index 2960216..ef3753d 100644 --- a/apps/api/src/app/routes/governance.ts +++ b/apps/api/src/app/routes/governance.ts @@ -1,107 +1,108 @@ /** * 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 —— * 每个功能是对应模块的管理用例 + 本文件的路由组装)。 + * 端级授权由 server.ts 的鉴权中间件按路由归属统一判(ADR-012),这里不再各写守卫。 */ import type { Hono } from 'hono'; -import type { ApiError, GovernanceFunction } from '@contracts'; +import type { ApiError } from '@contracts'; import { - createStaffRoute, - disableStaffCheckRoute, - disableStaffRoute, - listGrantsRoute, + deleteLlmInstanceRoute, + deleteRoleAccessRoute, listLlmInstancesRoute, + listLlmProvidersRoute, + listRoleAccessRoute, listStaffRoute, listWorkGroupsRoute, - positionIdSchema, - listLlmProvidersRoute, LLM_PROVIDERS, - deleteLlmInstanceRoute, probeLlmDraftRoute, probeLlmInstanceRoute, saveWorkGroupRoute, - setGrantsRoute, + setRoleAccessRoute, + setStaffPositionRoute, staffIdSchema, + staffInFlightRoute, + syncStaffRoute, upsertLlmInstanceRoute, } from '@contracts'; -import { DisableBlockedError } from '@modules/org'; +import { AccessLockoutError, PositionNotFoundError, StaffNotFoundError } from '@modules/org'; import { PROVIDER_PROFILES } from '@modules/llm'; import type { Container } from '../container.ts'; import type { AppEnv } from '../http-env.ts'; -/** 治理动作要过岗位授权(客体 = 治理功能,动作 = 治理) */ -function requireGovern(container: Container, fn: GovernanceFunction) { - return async (account: string): Promise => { - const staff = await container.org.findByAccount(account); - if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' }; - const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, 'govern'); - return ok ? undefined : { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」治理权` }; - }; -} - export function registerGovernanceRoutes(app: Hono, container: Container): void { - const guardStaff = requireGovern(container, 'staff'); - const guardSystem = requireGovern(container, 'system'); + /* ── 员工管理:名册来自鉴权中心,这里只管岗位与同步(ADR-012) ─────────── */ - /* ── 员工管理 ─────────────────────────────────────────────────────── */ + app.get(listStaffRoute.path, async (c) => c.json(await container.org.listStaff())); - app.get(listStaffRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - return c.json(await container.org.listStaff()); - }); + /** 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎) */ + app.post(syncStaffRoute.path, async (c) => c.json(await container.org.syncRoster())); - app.post(createStaffRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const parsed = createStaffRoute.body.safeParse(await c.req.json()); + app.put(setStaffPositionRoute.path, async (c) => { + const staffId = staffIdSchema.parse(c.req.param('staffId')); + const parsed = setStaffPositionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; return c.json(body, 400); } - return c.json(await container.org.createStaff(parsed.data)); - }); - - app.get(disableStaffCheckRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const staffId = staffIdSchema.parse(c.req.param('staffId')); - return c.json(await container.org.checkDisable(staffId)); - }); - - app.post(disableStaffRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const staffId = staffIdSchema.parse(c.req.param('staffId')); try { - await container.org.disableStaff(staffId); - return c.json({ ok: true, check: await container.org.checkDisable(staffId) }); + return c.json(await container.org.setPosition(staffId, parsed.data.positionId)); } catch (error) { - if (error instanceof DisableBlockedError) { - // 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) - const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.check }; - return c.json(body, 412); + if (error instanceof StaffNotFoundError || error instanceof PositionNotFoundError) { + const body: ApiError = { code: 'not-found', message: error.message }; + return c.json(body, 404); } throw error; } }); - app.get(listGrantsRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const positionId = positionIdSchema.parse(c.req.param('positionId')); - return c.json({ grants: await container.org.listGrants(positionId) }); + /** 此人身上还挂着什么 —— 原「停用前置检查」降为只读信息;停用在 Keycloak 做 */ + app.get(staffInFlightRoute.path, async (c) => { + const staffId = staffIdSchema.parse(c.req.param('staffId')); + try { + return c.json(await container.org.inFlightOf(staffId)); + } catch (error) { + if (error instanceof StaffNotFoundError) { + const body: ApiError = { code: 'not-found', message: error.message }; + return c.json(body, 404); + } + throw error; + } }); - app.post(setGrantsRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const positionId = positionIdSchema.parse(c.req.param('positionId')); - const parsed = setGrantsRoute.body.safeParse(await c.req.json()); + /* ── 角色 → 端(ADR-012) ─────────────────────────────────────────────── */ + + app.get(listRoleAccessRoute.path, async (c) => c.json(await container.org.listRoleAccess())); + + /** 锁死不变量不过(改完没人能进运营中心)→ 409 */ + app.put(setRoleAccessRoute.path, async (c) => { + const role = c.req.param('role'); + const parsed = setRoleAccessRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; return c.json(body, 400); } - return c.json({ grants: await container.org.setGrants(positionId, parsed.data.grants) }); + try { + return c.json(await container.org.setRoleAccess(role, parsed.data.ends)); + } catch (error) { + if (error instanceof AccessLockoutError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + throw error; + } + }); + + app.delete(deleteRoleAccessRoute.path, async (c) => { + try { + await container.org.deleteRoleAccess(c.req.param('role')); + return c.json({ ok: true }); + } catch (error) { + if (error instanceof AccessLockoutError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + throw error; + } }); /* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */ @@ -111,8 +112,6 @@ export function registerGovernanceRoutes(app: Hono, container: Container * `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。 */ app.get(listWorkGroupsRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const { staff } = await container.org.listStaff(); const found = await Promise.all( staff.map(async (s) => { @@ -129,8 +128,6 @@ export function registerGovernanceRoutes(app: Hono, container: Container * 改组**不影响在途**:装进工作流的是起单时的快照。 */ app.put(saveWorkGroupRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues }; @@ -149,10 +146,8 @@ export function registerGovernanceRoutes(app: Hono, container: Container /* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */ /** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */ - app.get(listLlmProvidersRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - return c.json({ + app.get(listLlmProvidersRoute.path, (c) => + c.json({ providers: LLM_PROVIDERS.map((provider) => { const profile = PROVIDER_PROFILES[provider]; return { @@ -162,18 +157,12 @@ export function registerGovernanceRoutes(app: Hono, container: Container structuredOutput: profile.structuredOutput, }; }), - }); - }); + }), + ); - app.get(listLlmInstancesRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - return c.json({ instances: await container.llm.list() }); - }); + app.get(listLlmInstancesRoute.path, async (c) => c.json({ instances: await container.llm.list() })); app.post(upsertLlmInstanceRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; @@ -184,8 +173,6 @@ export function registerGovernanceRoutes(app: Hono, container: Container /** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */ app.delete(deleteLlmInstanceRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const id = c.req.param('id'); const target = (await container.llm.list()).find((i) => i.id === id); if (target === undefined) { @@ -208,8 +195,6 @@ export function registerGovernanceRoutes(app: Hono, container: Container /** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */ app.post(probeLlmDraftRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; @@ -218,9 +203,5 @@ export function registerGovernanceRoutes(app: Hono, container: Container return c.json(await container.llm.probeDraft(parsed.data)); }); - app.post(probeLlmInstanceRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - return c.json(await container.llm.probe(c.req.param('id'))); - }); + app.post(probeLlmInstanceRoute.path, async (c) => c.json(await container.llm.probe(c.req.param('id')))); } diff --git a/apps/api/src/app/routes/registration.ts b/apps/api/src/app/routes/registration.ts index a3e71ee..684f633 100644 --- a/apps/api/src/app/routes/registration.ts +++ b/apps/api/src/app/routes/registration.ts @@ -10,6 +10,7 @@ import type { Hono } from 'hono'; import type { ApiError } from '@contracts'; import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts'; import type { Container } from '../container.ts'; +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; export function registerRegistrationRoutes(app: Hono, container: Container): void { @@ -17,7 +18,7 @@ export function registerRegistrationRoutes(app: Hono, container: Contain /** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */ app.get(listRegistrationTriggersRoute.path, async (c) => { - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); if (me === undefined) return c.json(unauthenticated, 401); const triggers = await container.trigger.list(); @@ -38,7 +39,7 @@ export function registerRegistrationRoutes(app: Hono, container: Contain }); app.post(fireRegistrationRoute.path, async (c) => { - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); if (me === undefined) return c.json(unauthenticated, 401); const parsed = fireRegistrationRoute.body.safeParse(await c.req.json()); if (!parsed.success) { diff --git a/apps/api/src/app/routes/workbench.ts b/apps/api/src/app/routes/workbench.ts index 66e9b0e..8471d1e 100644 --- a/apps/api/src/app/routes/workbench.ts +++ b/apps/api/src/app/routes/workbench.ts @@ -1,13 +1,13 @@ /** * 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。 - * 登录 / 登出属鉴权面:中间件在 app 层,身份判定读 `org` 出口(§4 / ADR-010)。 + * 登录已归统一鉴权中心(ADR-012):这里只剩「我是谁」与票据。 */ import type { Hono } from 'hono'; import type { ApiError } from '@contracts'; -import { CONTRACTS_VERSION, healthRoute, loginRoute, logoutRoute, sessionRoute } from '@contracts'; +import { CONTRACTS_VERSION, authTicketRoute, healthRoute, meRoute } from '@contracts'; import type { Container } from '../container.ts'; -import { SESSION_COOKIE, clearSessionCookie, setSessionCookie } from '../session-cookie.ts'; import type { AppEnv } from '../http-env.ts'; +import { systemClock } from '../runtime.ts'; export function registerWorkbenchRoutes(app: Hono, container: Container): void { app.get(healthRoute.path, async (c) => { @@ -21,44 +21,20 @@ export function registerWorkbenchRoutes(app: Hono, container: Container) return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db }); }); - /** 我是谁 —— 会话由中间件解好放进上下文,这里只回投影 */ - app.get(sessionRoute.path, (c) => { - const session = c.get('session'); - if (session === undefined) { - const body: ApiError = { code: 'unauthenticated', message: '尚未登录' }; - return c.json(body, 401); - } - return c.json(session); + const unauthenticated: ApiError = { code: 'unauthenticated', message: '缺少 Bearer token' }; + + /** 我是谁、我能进哪几个端 —— 中间件已把 principal 放进上下文,这里只回投影;ends 为空也 200 */ + app.get(meRoute.path, (c) => { + const principal = c.get('principal'); + if (principal === undefined) return c.json(unauthenticated, 401); + return c.json(principal); }); - app.post(loginRoute.path, async (c) => { - const parsed = loginRoute.body.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) { - const body: ApiError = { code: 'validation-failed', message: '请填写账号与口令' }; - return c.json(body, 400); - } - - const issued = await container.org.login(parsed.data.account, parsed.data.password); - // 失败只有一句话:不区分账号不存在 / 已停用 / 口令错,不外泄账号是否存在 - if (issued === undefined) { - const body: ApiError = { code: 'unauthenticated', message: '账号或口令不对' }; - return c.json(body, 401); - } - - const session = await container.org.resolveSession(issued.token); - if (session === undefined) { - const body: ApiError = { code: 'internal', message: '会话签发后立即失效' }; - return c.json(body, 500); - } - setSessionCookie(c, issued.token, new Date(issued.expiresAt)); - return c.json(session); - }); - - app.post(logoutRoute.path, async (c) => { - const token = c.req.header('cookie')?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`))?.[1]; - // 服务端表先作废,再清 cookie —— 顺序反了会留下「cookie 没了但会话还活着」的窗口 - if (token !== undefined) await container.org.logout(token); - clearSessionCookie(c); - return c.json({ ok: true }); + /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 下载这两条带不了 Authorization 头的路 */ + app.get(authTicketRoute.path, (c) => { + const principal = c.get('principal'); + if (principal === undefined) return c.json(unauthenticated, 401); + const issued = container.tickets.issue(principal.staff.id, systemClock.now()); + return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt.toISOString() }); }); } diff --git a/apps/api/src/app/seed.ts b/apps/api/src/app/seed.ts index 4927bc0..ff7b350 100644 --- a/apps/api/src/app/seed.ts +++ b/apps/api/src/app/seed.ts @@ -11,8 +11,8 @@ export async function seed(): Promise { try { await container.org.ensureSeeded(); const { staff, positions } = await container.org.listStaff(); - process.stdout.write(`种子完成:${positions.length} 个岗位 / ${staff.length} 名员工\n`); - for (const s of staff) process.stdout.write(` ${s.account.padEnd(12)} ${s.name}(${s.position.code})\n`); + process.stdout.write(`种子完成:${positions.length} 个岗位;名册 ${staff.length} 人(人来自鉴权中心:首登自动建档 / 运营中心同步)\n`); + for (const s of staff) process.stdout.write(` ${s.account.padEnd(24)} ${s.name}(${s.position.code})\n`); // MVP 底座:四域数据 · 六活动槽位 · 1:1 流程包(README MVP 完成判据第 1 条) process.stdout.write('\n── 治理台配置 ──\n'); diff --git a/apps/api/src/app/server.ts b/apps/api/src/app/server.ts index f86d6e5..48becea 100644 --- a/apps/api/src/app/server.ts +++ b/apps/api/src/app/server.ts @@ -1,13 +1,26 @@ /** * Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。 - * 鉴权、岗位权限、SSE 全归 app 层;不暴露 Mastra 默认端点。 + * 鉴权、端归属、SSE 全归 app 层;不暴露 Mastra 默认端点。 */ import { Hono } from 'hono'; -import type { ApiError } from '@contracts'; -import { healthRoute, ingestMailRoute, loginRoute } from '@contracts'; +import * as contracts from '@contracts'; +import type { ApiError, RouteContract } from '@contracts'; +import { + createStaffRoute, + disableStaffCheckRoute, + disableStaffRoute, + listGrantsRoute, + loginRoute, + logoutRoute, + sessionRoute, + setGrantsRoute, +} from '@contracts'; import type { Container } from './container.ts'; import type { AppEnv } from './http-env.ts'; -import { readSessionToken } from './session-cookie.ts'; +import { createAuthMiddleware } from './auth-middleware.ts'; +import { buildRouteRegistry, routesOf } from './route-registry.ts'; +import type { RegisteredRoute, RouteRegistry } from './route-registry.ts'; +import { systemClock } from './runtime.ts'; import { registerDeskRoutes } from './routes/desk.ts'; import { registerDeskElementRoutes } from './routes/desk-elements.ts'; import { registerDeskApprovalRoutes } from './routes/desk-approval.ts'; @@ -20,41 +33,41 @@ import { registerWorkbenchRoutes } from './routes/workbench.ts'; export type { AppEnv }; -/** - * 免鉴权白名单(ADR-010)—— 只三条,其余一律 401。 - * 入站邮件是**外界边界**:它代替真实 IMAP 拉件,不是人的请求,没有会话可言。 - */ -const INGEST_PREFIX = ingestMailRoute.path.slice(0, ingestMailRoute.path.indexOf(':')); +/** 字面路径注册、不在契约里的路由 —— 注册表得知道它们归哪儿(SSE 只要登录) */ +export const EXTRA_ROUTES: readonly RegisteredRoute[] = [{ method: 'GET', path: '/api/stream', access: 'any' }]; -function isPublic(method: string, path: string): boolean { - if (method === 'GET' && path === healthRoute.path) return true; - if (method === 'POST' && path === loginRoute.path) return true; - if (method === 'POST' && path.startsWith(INGEST_PREFIX)) return true; - return false; +/** 全部路由的归属(ADR-012)—— 契约命名空间扫一遍 + 字面路径补入;新路由写在契约里就自动生效 */ +export function appRouteRegistry(): RouteRegistry { + return buildRouteRegistry([...routesOf(Object.values(contracts)), ...EXTRA_ROUTES]); } +/** + * 随统一鉴权中心退役的路由(§12.4 三步之第二步):契约留一轮给 web 编译,后端一律 410 并指路。 + * 它们的 access 是 public —— 410 不需要凭据,也不泄漏任何东西。 + */ +const RETIRED_ROUTES: ReadonlyArray<{ readonly route: RouteContract; readonly hint: string }> = [ + { route: loginRoute, hint: '登录由 Keycloak 完成,请求带 Authorization: Bearer ' }, + { route: logoutRoute, hint: '登出在前端丢弃 token,或调 Keycloak 的 end-session' }, + { route: sessionRoute, hint: '改用 GET /api/me' }, + { route: createStaffRoute, hint: '人来自鉴权中心 —— 首次登录自动建档,或 POST /api/gov/staff/sync 同步名册' }, + { route: disableStaffCheckRoute, hint: '改用 GET /api/gov/staff/:staffId/in-flight' }, + { route: disableStaffRoute, hint: '在 Keycloak 停用账号后 POST /api/gov/staff/sync' }, + { route: listGrantsRoute, hint: '岗位授权已退役,改用 GET /api/gov/access/roles(角色→端)' }, + { route: setGrantsRoute, hint: '岗位授权已退役,改用 PUT /api/gov/access/roles/:role' }, +]; + export function createServer(container: Container): Hono { const app = new Hono(); /** - * 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-010)。 - * - * 身份**只能**来自服务端会话表:cookie 里是一枚不透明 token,改它只会让会话解不出来, - * 冒充不了任何人 —— 这正是明文 `jcai_account` cookie 被废止的理由。 + * 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-012)。 + * 凭据只认验过签的 Bearer token(或由它换来的 60 秒票据);归属按路由注册表统一判, + * 路由里不再各写守卫 —— 一处拦住,新路由自动生效。 */ - app.use('*', async (c, next) => { - const token = readSessionToken(c.req.header('cookie')); - const session = token === undefined ? undefined : await container.org.resolveSession(token); - c.set('session', session); - c.set('account', session?.staff.account ?? ''); - - if (session === undefined && !isPublic(c.req.method, c.req.path)) { - const body: ApiError = { code: 'unauthenticated', message: '尚未登录' }; - return c.json(body, 401); - } - await next(); - return undefined; - }); + app.use( + '*', + createAuthMiddleware({ registry: appRouteRegistry(), org: container.org, tickets: container.tickets, clock: systemClock }), + ); /** * 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。 @@ -92,6 +105,13 @@ export function createServer(container: Container): Hono { return c.json(body, 404); }); + for (const { route, hint } of RETIRED_ROUTES) { + app.on(route.method, route.path, (c) => { + const body: ApiError = { code: 'gone', message: `已随统一鉴权中心退役:${hint}` }; + return c.json(body, 410); + }); + } + registerWorkbenchRoutes(app, container); registerGovernanceRoutes(app, container); registerDeskRoutes(app, container); diff --git a/apps/api/src/app/session-cookie.ts b/apps/api/src/app/session-cookie.ts deleted file mode 100644 index 2bb5d27..0000000 --- a/apps/api/src/app/session-cookie.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * 会话 cookie 的读写(ADR-010)—— 只此一处拼 `Set-Cookie`,属性不许各写各的。 - * - * `HttpOnly`:脚本读不到 token,XSS 拿不走会话。 - * `SameSite=Lax`:跨站表单 POST 不带 cookie,挡掉 CSRF 的常见形态; - * 同站导航仍带,登录后跳转不受影响。 - * 开发期走 http://localhost,故 `Secure` 随部署环境开关(生产必开)。 - */ -import type { Context } from 'hono'; -import type { AppEnv } from './http-env.ts'; - -export const SESSION_COOKIE = 'jcai_session'; - -/** 生产环境必带 Secure;开发期 localhost 是 http,带了浏览器就不存 */ -function secureAttr(): string { - return process.env['NODE_ENV'] === 'production' ? '; Secure' : ''; -} - -export function readSessionToken(cookieHeader: string | undefined): string | undefined { - if (cookieHeader === undefined) return undefined; - const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(cookieHeader); - return match?.[1]; -} - -export function setSessionCookie(c: Context, token: string, expiresAt: Date): void { - c.header( - 'Set-Cookie', - `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Expires=${expiresAt.toUTCString()}`, - { append: true }, - ); -} - -export function clearSessionCookie(c: Context): void { - c.header( - 'Set-Cookie', - `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Max-Age=0`, - { append: true }, - ); -} diff --git a/apps/api/src/app/set-password.ts b/apps/api/src/app/set-password.ts deleted file mode 100644 index e321231..0000000 --- a/apps/api/src/app/set-password.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 改密 CLI(ADR-010)—— 治理台的改密界面还没做,先给运维一条路。 - * - * pnpm --filter @app/api exec node --experimental-strip-types src/app/set-password.ts <账号> <新口令> - * - * 口令从**命令行参数**取,不写进任何文件;落库前即散列,明文只在进程内存里停留一瞬。 - */ -import { loadConfig } from './config.ts'; -import { createContainer } from './container.ts'; - -export async function setPassword(account: string, password: string): Promise { - const container = await createContainer(loadConfig()); - try { - const staff = await container.org.findByAccount(account); - if (staff === undefined) throw new Error(`账号不存在:${account}`); - await container.org.setPassword(staff.id, password); - process.stdout.write(`已改密:${staff.name}(${account})\n`); - } finally { - await container.close(); - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - const [account, password] = process.argv.slice(2); - if (account === undefined || password === undefined) { - process.stderr.write('用法:set-password.ts <账号> <新口令>\n'); - process.exit(1); - } - await setPassword(account, password); - process.exit(0); -} diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 0ec800f..bba18d1 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -17,6 +17,7 @@ import { renderView } from '@modules/interaction'; import type { InteractionService } from '@modules/interaction'; import type { MessageService } from '@modules/message'; import type { OrgService } from '@modules/org'; +import { NONE_POSITION_CODE } from '@modules/org'; import type { LlmService, ModelGateway } from '@modules/llm'; import type { ActivityRegistryService } from '@modules/activity-registry'; import type { SkillEngineService } from '@modules/skill-engine'; @@ -2453,7 +2454,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, }, org: { - positions: async () => new Set((await deps.org.listStaff()).positions.map((p) => p.code)), + // 哨兵岗 NONE(未分配)不是流程包能声明的岗位 —— 不进候选集(ADR-012) + positions: async () => + new Set( + (await deps.org.listStaff()).positions.filter((p) => p.code !== NONE_POSITION_CODE).map((p) => p.code), + ), candidates: async (position) => { const staff = await deps.org.staffOfPosition(position); const withLoad = await Promise.all( @@ -2614,10 +2619,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { // 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃 report: async (input) => { writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`); - const { staff } = await deps.org.listStaff(); - for (const person of staff) { - const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern'); - if (!governs) continue; + // 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端) + for (const person of await deps.org.staffWithEnd('ops')) { await deps.message.notify({ staffId: person.id, title: `触发器起单失败:${input.triggerId}`, diff --git a/apps/api/src/modules/org/__tests__/authorization.test.ts b/apps/api/src/modules/org/__tests__/authorization.test.ts deleted file mode 100644 index 6e66817..0000000 --- a/apps/api/src/modules/org/__tests__/authorization.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/api/src/modules/org/__tests__/org-service.test.ts b/apps/api/src/modules/org/__tests__/org-service.test.ts new file mode 100644 index 0000000..a92b4d6 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/org-service.test.ts @@ -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; + tokens: Map; + staff: Map; + roleAccess: Map; + groups: Map; + positions: Map; + calls: { updateIdentity: number }; + advance(ms: number): void; +}; + +function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}): Harness { + const positions = new Map(); + const staff = new Map(); + const roleAccess = new Map(); + const groups = new Map(); + const tokens = new Map(); + 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 & { 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' }); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/password.test.ts b/apps/api/src/modules/org/__tests__/password.test.ts deleted file mode 100644 index b8a66b6..0000000 --- a/apps/api/src/modules/org/__tests__/password.test.ts +++ /dev/null @@ -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_-]+$/); - }); -}); diff --git a/apps/api/src/modules/org/contract.ts b/apps/api/src/modules/org/contract.ts index dab26fc..e8eec2d 100644 --- a/apps/api/src/modules/org/contract.ts +++ b/apps/api/src/modules/org/contract.ts @@ -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; - findByAccount(account: string): Promise; - createStaff(input: CreateStaffInput): Promise; - /** 停用前置检查 —— 只读,治理台先看再点 */ - checkDisable(staffId: StaffId): Promise; - /** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */ - disableStaff(staffId: StaffId): Promise; - listGrants(positionId: string): Promise; - setGrants(positionId: string, grants: readonly Grant[]): Promise; - /** 岗位权限校验 —— app 层鉴权中间件读它 */ - authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise; - loadSession(account: string): Promise; - - /* ── 登录会话(ADR-010)—— 中间件在 app 层,身份判定读这里 ───────────── */ - - /** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */ - login(account: string, password: string): Promise; - /** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */ - logout(token: string): Promise; - /** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */ - resolveSession(token: string): Promise; - /** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */ - setPassword(staffId: StaffId, password: string): Promise; - - /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */ - - /** 建组 / 改组;id 留空即新建 */ - saveWorkGroup(input: { - id?: string; - name: string; - ownerId: StaffId; - members: Readonly>; - }): Promise; - /** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */ - workGroupOf(staffId: StaffId): Promise; - /** 装进工作流的快照:改组不改已起的单 */ - bindingOf(staffId: StaffId): Promise; - - /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */ - staffOfPosition(positionCode: string): Promise; - ensureSeeded(): Promise; -}; - -/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ - -/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */ -export type Identity = { - readonly sub: string; - /** preferred_username */ - readonly account: string; - /** name claim,缺则回退 account */ - readonly name: string; - readonly email?: string; - /** realm 角色,噪声角色已滤、已排序 */ - readonly roles: readonly string[]; -}; - /** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */ export class TokenRejectedError extends Error { constructor(message: string) { @@ -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; + /** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */ + principalOf(staffId: StaffId): Promise; + /** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */ + registerIdentity(identity: Identity): Promise; + + /* ── 名册与岗位 ───────────────────────────────────────────────────── */ + + listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>; + getStaff(staffId: StaffId): Promise; + /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */ + staffOfPosition(positionCode: string): Promise; + setPosition(staffId: StaffId, positionId: PositionId): Promise; + /** Admin API 同步;未配 provisioning 回 configured:false */ + syncRoster(): Promise; + /** 此人身上还挂着什么(只读;OS 02 改派先于停用) */ + inFlightOf(staffId: StaffId): Promise; + + /* ── 角色 → 端 ───────────────────────────────────────────────────── */ + + listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>; + /** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */ + setRoleAccess(role: string, ends: readonly Surface[]): Promise; + deleteRoleAccess(role: string): Promise; + /** 能进该端的活跃员工 —— 治理台通知的收件人 */ + staffWithEnd(surface: Surface): Promise; + + /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */ + + /** 建组 / 改组;id 留空即新建 */ + saveWorkGroup(input: { + id?: string; + name: string; + ownerId: StaffId; + members: Readonly>; + }): Promise; + /** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */ + workGroupOf(staffId: StaffId): Promise; + /** 装进工作流的快照:改组不改已起的单 */ + bindingOf(staffId: StaffId): Promise; + + /** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */ + ensureSeeded(): Promise; +}; diff --git a/apps/api/src/modules/org/index.ts b/apps/api/src/modules/org/index.ts index a253854..f920fb0 100644 --- a/apps/api/src/modules/org/index.ts +++ b/apps/api/src/modules/org/index.ts @@ -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'; diff --git a/apps/api/src/modules/org/internal/adapter/org-repository.ts b/apps/api/src/modules/org/internal/adapter/org-repository.ts index a84dc88..84b99ad 100644 --- a/apps/api/src/modules/org/internal/adapter/org-repository.ts +++ b/apps/api/src/modules/org/internal/adapter/org-repository.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)); }, }; } diff --git a/apps/api/src/modules/org/internal/adapter/org-schema.ts b/apps/api/src/modules/org/internal/adapter/org-schema.ts index 5937fcc..2678055 100644 --- a/apps/api/src/modules/org/internal/adapter/org-schema.ts +++ b/apps/api/src/modules/org/internal/adapter/org-schema.ts @@ -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().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」的绑定。 diff --git a/apps/api/src/modules/org/internal/adapter/password.ts b/apps/api/src/modules/org/internal/adapter/password.ts deleted file mode 100644 index 10a190f..0000000 --- a/apps/api/src/modules/org/internal/adapter/password.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 口令散列(ADR-010)—— Node 内置 scrypt,零第三方依赖。 - * - * 串格式自描述:`scrypt$N$r$p$$`。 - * 参数写进串里,日后调参不必迁移旧记录:比对时按**该记录自己的**参数重算。 - * 明文永不落库,比对走 `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 { - 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 { - 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 { - 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)); -} diff --git a/apps/api/src/modules/org/internal/domain/authorization.ts b/apps/api/src/modules/org/internal/domain/authorization.ts deleted file mode 100644 index 98b744c..0000000 --- a/apps/api/src/modules/org/internal/domain/authorization.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 岗位授权判定 —— 纯逻辑,零 IO(编码规范 §5.3)。 - * - * 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。 - * 语义出处:OS 02 员工管理。 - */ -import type { Grant, GrantAction, GrantObject } from '@contracts'; - -/** 动作蕴含:治理 ⊃ 写 ⊃ 读 —— 有写权即可读,有治理权即可写 */ -const IMPLIES: Readonly> = { - 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; - 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; - return granted.function === req.function; - } - case 'task': { - const req = required as Extract; - // 未限定活动 = 覆盖全部任务 - 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)); -} diff --git a/apps/api/src/modules/org/internal/domain/disable-check.ts b/apps/api/src/modules/org/internal/domain/disable-check.ts deleted file mode 100644 index cc39c19..0000000 --- a/apps/api/src/modules/org/internal/domain/disable-check.ts +++ /dev/null @@ -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, - }; -} diff --git a/apps/api/src/modules/org/internal/domain/seed-data.ts b/apps/api/src/modules/org/internal/domain/seed-data.ts index cbc5891..5b8ff1b 100644 --- a/apps/api/src/modules/org/internal/domain/seed-data.ts +++ b/apps/api/src/modules/org/internal/domain/seed-data.ts @@ -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>> = { - /** 测试岗与 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; diff --git a/apps/api/src/modules/org/internal/usecase/org-service.ts b/apps/api/src/modules/org/internal/usecase/org-service.ts index 68f0d3e..d38707d 100644 --- a/apps/api/src/modules/org/internal/usecase/org-service.ts +++ b/apps/api/src/modules/org/internal/usecase/org-service.ts @@ -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> { @@ -60,142 +66,188 @@ export function createOrgService(deps: OrgServiceDeps): OrgService { return { ...staff, position }; } - async function grantsOfStaff(staffId: StaffId): Promise { - 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 { + const position = await deps.positions.findByCode(code); + if (!position) throw new PositionNotFoundError(code); + return position; } - return { + async function principalFor(record: StaffRecord): Promise { + 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 { + 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 { + 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 { + 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 { + 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(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 { - 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 { - 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 { - 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(); 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; } diff --git a/apps/api/src/modules/org/port.ts b/apps/api/src/modules/org/port.ts index cd198be..187738f 100644 --- a/apps/api/src/modules/org/port.ts +++ b/apps/api/src/modules/org/port.ts @@ -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; - findByCode(code: string): Promise; - findById(id: PositionId): Promise; - upsert(position: Omit & { id?: PositionId }): Promise; -}; - -export type StaffRepository = { - list(): Promise; - findById(id: StaffId): Promise; - findByAccount(account: string): Promise; - listByPosition(positionId: PositionId): Promise; - insert(staff: Staff): Promise; - setState(id: StaffId, state: Staff['state']): Promise; - /** 口令散列的读写 —— 明文不经此端口(ADR-010) */ - passwordHashOf(account: string): Promise; - setPasswordHash(id: StaffId, hash: string): Promise; -}; - -/** - * 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。 - * token 即主键,不透明随机串,不编码任何身份信息。 - */ -export type SessionRepository = { - issue(input: { token: string; staffId: StaffId; expiresAt: Date }): Promise; - /** 未过期才返回;命中同时滑动续期 */ - touch(token: string, now: Date, expiresAt: Date): Promise<{ staffId: StaffId } | undefined>; - revoke(token: string): Promise; - revokeAllOf(staffId: StaffId): Promise; -}; - -/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */ -export type PasswordHasher = { - hash(password: string): Promise; - verify(password: string, stored: string | undefined): Promise; - newToken(): string; -}; - -export type GrantRepository = { - listByPosition(positionId: PositionId): Promise; - replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise; -}; - -/** - * 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`, - * 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。 - */ -export type InFlightWorkProbe = { - openTasksOf(staffId: StaffId): Promise>; - openCardsOf(staffId: StaffId): Promise>; - openApprovalsOf(staffId: StaffId): Promise>; -}; - -/** 工作组仓储(ADR-011) */ -export type WorkGroupRepository = { - listByOwner(ownerId: StaffId): Promise; - upsert(group: WorkGroup): Promise; -}; - -/** 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; + findByCode(code: string): Promise; + findById(id: PositionId): Promise; + upsert(position: Omit & { id?: PositionId }): Promise; +}; + +/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ +export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date }; + +export type StaffRepository = { + list(): Promise; + findById(id: StaffId): Promise; + listByPosition(positionId: PositionId): Promise; + insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise; + updateIdentity(id: StaffId, patch: IdentityPatch): Promise; + setPosition(id: StaffId, positionId: PositionId): Promise; + setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; +}; + +export type RoleAccessRepository = { + list(): Promise; + upsert(row: RoleAccess): Promise; + remove(role: string): Promise; +}; + +/** + * 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`, + * 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。 + */ +export type InFlightWorkProbe = { + openTasksOf(staffId: StaffId): Promise>; + openCardsOf(staffId: StaffId): Promise>; + openApprovalsOf(staffId: StaffId): Promise>; +}; + +/** 工作组仓储(ADR-011) */ +export type WorkGroupRepository = { + listByOwner(ownerId: StaffId): Promise; + upsert(group: WorkGroup): Promise; +}; + +/** ID 生成 —— domain 零随机(§5.3) */ +export type IdGenerator = { next(): string }; diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index bfbcc84..d5c2a54 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -5,8 +5,6 @@ export default defineConfig({ main: 'src/app/main.ts', seed: 'src/app/seed.ts', migrate: 'src/app/migrate.ts', - // 改密 CLI(ADR-010):治理台改密界面未做前的运维口子 - 'set-password': 'src/app/set-password.ts', // 清运行数据:开发期反复用 'reset-runs': 'src/app/reset-runs.ts', // 只装活动包(不碰治理台配置)—— 开发活动时一天要装十几次 diff --git a/db/migrations/0032_keycloak_identity.sql b/db/migrations/0032_keycloak_identity.sql new file mode 100644 index 0000000..f0e8ed9 --- /dev/null +++ b/db/migrations/0032_keycloak_identity.sql @@ -0,0 +1,27 @@ +-- 身份归统一鉴权中心(ADR-012):会话表与岗位授权表退役;员工 id 从此就是 Keycloak 的 sub。 +-- +-- 从前(ADR-010)是自建账号口令 + 服务端会话表 + 岗位授权(客体三类 × 动作三种)。 +-- 现在:后端只验 Bearer JWT;权限只到「端」(design / ops / workbench), +-- 角色 → 端是运营中心可改的一张表;名册从 Keycloak 同步、岗位本地指派。 +drop table if exists sys.session; +drop table if exists sys.position_grant; + +alter table sys.staff + drop column if exists password_hash, + add column if not exists email text, + add column if not exists roles jsonb not null default '[]'::jsonb, + add column if not exists last_seen_at timestamptz, + add column if not exists synced_at timestamptz; + +create table if not exists sys.role_access ( + role text primary key, + design boolean not null default false, + ops boolean not null default false, + workbench boolean not null default false, + updated_at timestamptz not null default now() +); + +-- 种子人不是 Keycloak 的人:id 不是 sub,账号还会与 Keycloak 用户名撞唯一索引 —— 清掉。 +-- 他们名下的运行数据由 `reset-runs` 清(迁移不碰运行表)。 +delete from sys.work_group; +delete from sys.staff; diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 14fc57d..7e36b84 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -225,6 +225,13 @@ "when": 1785130120179, "tag": "0031_task_land_master", "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1785130120180, + "tag": "0032_keycloak_identity", + "breakpoints": true } ] -} \ No newline at end of file +}