157 lines
6.0 KiB
TypeScript
157 lines
6.0 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;
|
|||
|
|
/** disabled = 已停用;停用后该系统的调用一律拒绝,其他系统不受影响 */
|
|||
|
|
status: 'active' | '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' : '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 {
|
|||
|
|
const existing = this.resolve(subject, providerId);
|
|||
|
|
if (!existing) return undefined;
|
|||
|
|
return this.bind({ ...existing, status: 'disabled' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|