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:
Stev_Wang committed 2026-11-03 12:26:51 +08:00
1 parent f97f97b0cd
commit 61894df401
80 files changed
+14380 -237

No files matched your search

+28 -12
View File
@@ -7,6 +7,7 @@ import type { AuthContext, AuthenticatedRequest } from './auth.types';
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
import { PermissionService } from './permission.service';
import { RequestContextService } from './request-context';
import { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service';
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
@@ -123,9 +124,9 @@ export class PermissionGuard implements CanActivate {
return true;
}
const auth = requireAuth(context);
const missing = normal.filter((code) => !auth.permissions.includes(code));
if (missing.length > 0) {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { missing } });
// 多个权限点之间为 OR(任一满足即通过,见 @RequirePermissions 注释)
if (!normal.some((code) => auth.permissions.includes(code))) {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { required: normal } });
}
return true;
}
@@ -147,11 +148,10 @@ export class SpecialPermissionGuard implements CanActivate {
return true;
}
const auth = requireAuth(context);
const missing = special.filter((code) => !auth.permissions.includes(code));
if (missing.length > 0) {
if (!special.some((code) => auth.permissions.includes(code))) {
throw new AppException({
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
details: { missing },
details: { required: special },
});
}
return true;
@@ -159,16 +159,32 @@ export class SpecialPermissionGuard implements CanActivate {
}
/**
* 4) 数据范围守卫骨架:A2 按 dataScope 注入 ScopeContext(部门 / 客户集合)。
* A1 identity 接口不返回客户数据,非全局范围暂直接放行。
* 4) 数据范围守卫:解析 `ScopeContext`(六类范围 → 可见客户集合),注入 `request.scope`
* 与请求上下文;Repository 必须通过 `customerScopeWhere(ctx)` 强制客户维度过滤。
*
* 口径(技术架构 §5.5 / §7.1「失败处理」):解析失败一律 403(40302),不降级为「全部客户」;
* 公开接口(登录 / 健康检查)不解析。
*/
@Injectable()
export class ScopeGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const auth = authOf(context);
if (auth !== null && auth.dataScope.type !== 'all') {
// TODO(A2-BE-04):按 dataScope 注入 ScopeContext,并在 Repository 强制带 customer_id 过滤
constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(ScopeResolver) private readonly resolver: ScopeResolver,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (isPublic(this.reflector, context)) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const auth = authOf(context);
if (auth === null) {
return true;
}
const scope = await this.resolver.resolve(auth);
request.scope = scope;
this.requestContext.patch({ scope });
return true;
}
}