aps-agent/server/core/identity/context.ts

157 lines
6.0 KiB
TypeScript
Raw Normal View History

/**
* 身份上下文(任务 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;
}