统一鉴权中心接入(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
+47 -8
View File
@@ -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 !== ''),
};
}