169 lines
6.6 KiB
TypeScript
169 lines
6.6 KiB
TypeScript
/**
|
||
* 身份上下文(任务 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<string, AccountBinding>();
|
||
|
||
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;
|
||
} |