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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
This commit is contained in:
xcs
2026-08-25 17:40:55 +08:00
co-authored by Claude Opus 5
parent 81a185cd61
commit dd31b91829
38 changed files with 1236 additions and 1485 deletions
+61 -23
View File
@@ -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 与 <a href> 下载这两条带不了 Authorization 头的路 */
readonly tickets: TicketIssuer;
readonly ledger: LedgerService;
readonly event: EventService;
readonly llm: LlmService;
@@ -171,7 +175,13 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
export async function createContainer(config: AppConfig): Promise<Container> {
/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */
export type ContainerOverrides = {
readonly tokenVerifier?: TokenVerifier;
readonly roster?: RosterSource;
};
export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise<Container> {
/*
* 广播器**先建** —— 装配期就要往它上面接线。
*
@@ -197,25 +207,60 @@ export async function createContainer(config: AppConfig): Promise<Container> {
*/
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<Container> {
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<Container> {
pool,
db,
org,
tickets,
ledger,
event,
llm,