/** * 身份上下文(任务 A4.1):平台用户 → 各业务系统账号的绑定。 * * 两条边界(架构设计 4.1): * - 平台只记「这个平台用户在某个系统里是谁」,不复制业务系统的角色与权限(D9:权限归业务系统) * - 每个系统一条绑定、各自独立的凭据;一个系统登录态失效不影响另一个系统 * * 未绑定 = 平台不知道该以谁的身份调用该系统,策略层默认拒绝,不做「拿服务账号顶替」这类隐式降级。 */ import type { Database } from '../storage/db.js'; export interface IdentitySubject { userId: string; tenantId: string; } export interface AccountBinding { userId: string; tenantId: string; providerId: string; /** 该用户在业务系统里的账号标识(工号、登录名等),不含密码 */ accountId: string; displayName: string; /** active = 可用;expired = 登录态失效需重新绑定;disabled = 已停用。后两者都拒绝,但提示不同 */ status: 'active' | 'expired' | 'disabled'; createdAt: number; updatedAt: number; } export interface BindInput { userId: string; tenantId: string; providerId: string; accountId: string; displayName?: string; status?: AccountBinding['status']; } interface BindingRow { user_id: string; tenant_id: string; provider_id: string; account_id: string; display_name: string; status: string; created_at: number; updated_at: number; } const toBinding = (row: BindingRow): AccountBinding => ({ userId: row.user_id, tenantId: row.tenant_id, providerId: row.provider_id, accountId: row.account_id, displayName: row.display_name, status: row.status === 'disabled' ? 'disabled' : row.status === 'expired' ? 'expired' : 'active', createdAt: row.created_at, updatedAt: row.updated_at, }); export class IdentityContext { /** 没有数据库时的内存实现:开发与单测用,语义与落库版一致 */ private readonly memory = new Map(); constructor( private readonly db?: Database, private readonly now: () => number = Date.now, ) {} static key(subject: IdentitySubject, providerId: string): string { return `${subject.tenantId}:${subject.userId}:${providerId}`; } /** 绑定或更新一个系统账号(A5 起由 APS 侧的账号绑定流程调用) */ bind(input: BindInput): AccountBinding { const at = this.now(); const existing = this.resolve(input, input.providerId); const binding: AccountBinding = { userId: input.userId, tenantId: input.tenantId, providerId: input.providerId, accountId: input.accountId, displayName: input.displayName ?? input.accountId, status: input.status ?? 'active', createdAt: existing?.createdAt ?? at, updatedAt: at, }; if (this.db) { this.db .prepare( `insert into identity_bindings (user_id, tenant_id, provider_id, account_id, display_name, status, credential_ref, credential_iv, credential_ciphertext, credential_tag, created_at, updated_at) values (?, ?, ?, ?, ?, ?, null, null, null, null, ?, ?) on conflict(user_id, tenant_id, provider_id) do update set account_id = excluded.account_id, display_name = excluded.display_name, status = excluded.status, updated_at = excluded.updated_at`, ) .run(binding.userId, binding.tenantId, binding.providerId, binding.accountId, binding.displayName, binding.status, binding.createdAt, binding.updatedAt); } else { this.memory.set(IdentityContext.key(binding, binding.providerId), binding); } return binding; } unbind(subject: IdentitySubject, providerId: string): boolean { if (this.db) { const before = this.db .prepare('select count(*) as n from identity_bindings where user_id = ? and tenant_id = ? and provider_id = ?') .get(subject.userId, subject.tenantId, providerId) as { n: number } | undefined; this.db .prepare('delete from identity_bindings where user_id = ? and tenant_id = ? and provider_id = ?') .run(subject.userId, subject.tenantId, providerId); return (before?.n ?? 0) > 0; } return this.memory.delete(IdentityContext.key(subject, providerId)); } /** 停用:只影响这一个系统,其他系统的登录态与绑定不动 */ disable(subject: IdentitySubject, providerId: string): AccountBinding | undefined { return this.setStatus(subject, providerId, 'disabled'); } /** * 登录态失效:业务系统拒绝了这次调用(401/403),账号映射还在但凭据不能用了。 * 标成 expired 之后策略层会直接拒——不再拿旧凭据反复去撞系统。 */ expire(subject: IdentitySubject, providerId: string): AccountBinding | undefined { return this.setStatus(subject, providerId, 'expired'); } private setStatus(subject: IdentitySubject, providerId: string, status: AccountBinding['status']): AccountBinding | undefined { const existing = this.resolve(subject, providerId); if (!existing) return undefined; return this.bind({ ...existing, status }); } resolve(subject: IdentitySubject, providerId: string): AccountBinding | undefined { if (this.db) { const row = this.db .prepare('select user_id, tenant_id, provider_id, account_id, display_name, status, created_at, updated_at from identity_bindings where user_id = ? and tenant_id = ? and provider_id = ?') .get(subject.userId, subject.tenantId, providerId) as BindingRow | undefined; return row ? toBinding(row) : undefined; } return this.memory.get(IdentityContext.key(subject, providerId)); } list(subject: IdentitySubject): AccountBinding[] { if (this.db) { const rows = this.db .prepare('select user_id, tenant_id, provider_id, account_id, display_name, status, created_at, updated_at from identity_bindings where user_id = ? and tenant_id = ? order by provider_id') .all(subject.userId, subject.tenantId) as unknown as BindingRow[]; return rows.map(toBinding); } return [...this.memory.values()] .filter((binding) => binding.userId === subject.userId && binding.tenantId === subject.tenantId) .sort((a, b) => a.providerId.localeCompare(b.providerId)); } } /** 平台用户到某个系统账号的映射结果,注入给 Provider(不含任何凭据) */ export interface ResolvedIdentity { accountId: string; displayName: string; } export function resolvedIdentity(binding: AccountBinding | undefined): ResolvedIdentity | undefined { return binding ? { accountId: binding.accountId, displayName: binding.displayName } : undefined; }