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 全绿。
This commit is contained in:
1 parent
f97f97b0cd
commit
61894df401
80 files changed
+14380
-237
No files matched your search
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user