Files
lingxi/apps/api/src/common/security/scope/scope-context.ts
T
Stev_Wang 61894df401 feat(identity): 完成 A2 角色权限与数据范围全栈落地
背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。

改动:
- shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。
- prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。
- api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。
- web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。
- 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。

验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
2026-11-03 12:26:51 +08:00

73 lines
2.9 KiB
TypeScript
Raw 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.
import { type DataScope, ErrorCode } from '@lingxi/shared';
import { AppException } from '../../errors/app-exception';
/**
* 请求作用域上下文(技术架构 §5.5):登录用户 + 多角色并集后的可见客户集合。
*
* 解析顺序(技术架构 §7.1):
* 1. JWT / 账号状态由 JwtAuthGuard 校验;
* 2. 角色数据范围 → 客户集合(六类,CustomerDirectory 端口解析);
* 3. 多角色取并集;任一角色为「全部客户」即全部;
* 4. 单个资源请求显式指定客户时,必须落在集合内。
*
* 失败处理:解析失败一律 403(40302),**不降级为全部客户**。
*/
export interface ScopeContext {
userId: string;
/** 是否拥有 is_system 超管角色(隐式全部数据范围) */
isSuperAdmin: boolean;
/** 解析后的数据范围(每个元素带 customerIds;超管为 [{ type: 'all' }]) */
dataScopes: DataScope[];
/** null = 全部客户可见;否则为可见客户 id 集合(已排序去重;A3 前为空数组) */
customerIds: readonly string[] | null;
}
/**
* Repository 客户维度过滤参数(Prisma where 片段)。
* 使用方必须把返回值展开进 where:`where: { ...customerScopeWhere(ctx), ... }`,
* 禁止「先查全量再内存过滤」(AGENTS §3.5)。
*/
export interface CustomerScopeWhere {
customerId?: { in: string[] };
}
/** 是否可见全部客户 */
export function isAllCustomers(ctx: ScopeContext): boolean {
return ctx.customerIds === null;
}
/** 构造客户维度强制过滤片段:全部客户返回空对象,受限范围返回 `{ customerId: { in } }` */
export function customerScopeWhere(ctx: ScopeContext): CustomerScopeWhere {
return ctx.customerIds === null ? {} : { customerId: { in: [...ctx.customerIds] } };
}
/** 判断单个客户是否在范围内(用于单条资源的 404 / 403 语义选择) */
export function isCustomerVisible(ctx: ScopeContext, customerId: string): boolean {
return ctx.customerIds === null || ctx.customerIds.includes(customerId);
}
/**
* 断言对指定客户有写权限;范围外抛 40302(不泄露资源是否存在)。
* 单条**读**接口按 §10.3 应返回 404(避免探测存在性),调用方可先用 isCustomerVisible 判定。
*/
export function assertCustomerAccess(ctx: ScopeContext, customerId: string): void {
if (!isCustomerVisible(ctx, customerId)) {
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '客户不在当前数据范围之内',
});
}
}
/** 断言存在客户维度上下文(Repository 方法入口守卫,防止漏注入) */
export function requireScopeContext(ctx: ScopeContext | null | undefined): ScopeContext {
if (ctx === null || ctx === undefined) {
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '缺少数据范围上下文,已拒绝访问',
});
}
return ctx;
}