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

169 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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