背景: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 全绿。
191 lines
6.7 KiB
TypeScript
191 lines
6.7 KiB
TypeScript
import { ErrorCode, SPECIAL_PERMISSIONS } from '@lingxi/shared';
|
||
import { type CanActivate, type ExecutionContext, Inject, Injectable } from '@nestjs/common';
|
||
import { Reflector } from '@nestjs/core';
|
||
|
||
import { AppException } from '../errors/app-exception';
|
||
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[] = [
|
||
'/api/v1/me',
|
||
'/api/v1/auth/change-password',
|
||
'/api/v1/auth/logout',
|
||
'/api/v1/auth/refresh',
|
||
];
|
||
|
||
const SPECIAL_PERMISSION_SET: ReadonlySet<string> = new Set<string>(SPECIAL_PERMISSIONS);
|
||
|
||
function extractBearer(header: string | undefined): string | null {
|
||
if (!header?.startsWith('Bearer ')) {
|
||
return null;
|
||
}
|
||
const token = header.slice('Bearer '.length).trim();
|
||
return token.length > 0 ? token : null;
|
||
}
|
||
|
||
function isMustChangeAllowed(url: string): boolean {
|
||
const path = url.split('?')[0] ?? url;
|
||
return MUST_CHANGE_ALLOWED_PATHS.includes(path);
|
||
}
|
||
|
||
function isPublic(reflector: Reflector, context: ExecutionContext): boolean {
|
||
const value: unknown = reflector.getAllAndOverride(IS_PUBLIC_KEY, [
|
||
context.getHandler(),
|
||
context.getClass(),
|
||
]);
|
||
return value === true;
|
||
}
|
||
|
||
function requiredPermissions(reflector: Reflector, context: ExecutionContext): string[] {
|
||
const value: unknown = reflector.getAllAndOverride(PERMISSIONS_KEY, [
|
||
context.getHandler(),
|
||
context.getClass(),
|
||
]);
|
||
if (!Array.isArray(value)) {
|
||
return [];
|
||
}
|
||
return value.filter((code): code is string => typeof code === 'string');
|
||
}
|
||
|
||
function authOf(context: ExecutionContext): AuthContext | null {
|
||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||
return request.auth ?? null;
|
||
}
|
||
|
||
function requireAuth(context: ExecutionContext): AuthContext {
|
||
const auth = authOf(context);
|
||
if (auth === null) {
|
||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||
}
|
||
return auth;
|
||
}
|
||
|
||
/**
|
||
* 1) JWT 校验 → 每请求解析权限 → 强制改密拦截。
|
||
* 守卫顺序由 SecurityModule 的 APP_GUARD 注册顺序保证:
|
||
* JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||
*/
|
||
@Injectable()
|
||
export class JwtAuthGuard implements CanActivate {
|
||
constructor(
|
||
@Inject(Reflector) private readonly reflector: Reflector,
|
||
@Inject(TokenService) private readonly tokens: TokenService,
|
||
@Inject(PermissionService) private readonly permissions: PermissionService,
|
||
@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 token = extractBearer(request.headers.authorization);
|
||
if (token === null) {
|
||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||
}
|
||
const payload = this.tokens.verifyAccess(token);
|
||
const auth = await this.permissions.resolveAuth(payload.sub);
|
||
if (auth === null) {
|
||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||
}
|
||
if (auth.status === 'disabled') {
|
||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
|
||
}
|
||
if (auth.mustChangePassword && !isMustChangeAllowed(request.url)) {
|
||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '请先修改初始密码' });
|
||
}
|
||
request.auth = auth;
|
||
this.requestContext.patch({
|
||
userId: auth.userId,
|
||
account: auth.account,
|
||
permissions: auth.permissions,
|
||
});
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/** 2) 普通权限点校验;特殊权限留给 SpecialPermissionGuard(40301 与 40303 分开) */
|
||
@Injectable()
|
||
export class PermissionGuard implements CanActivate {
|
||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||
|
||
canActivate(context: ExecutionContext): boolean {
|
||
if (isPublic(this.reflector, context)) {
|
||
return true;
|
||
}
|
||
const normal = requiredPermissions(this.reflector, context).filter(
|
||
(code) => !SPECIAL_PERMISSION_SET.has(code),
|
||
);
|
||
if (normal.length === 0) {
|
||
return true;
|
||
}
|
||
const auth = requireAuth(context);
|
||
// 多个权限点之间为 OR(任一满足即通过,见 @RequirePermissions 注释)
|
||
if (!normal.some((code) => auth.permissions.includes(code))) {
|
||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { required: normal } });
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/** 3) 特殊权限(导出 / AI 配置 / 审计导出 / 行业发布 / 客户字段审批)单独校验 */
|
||
@Injectable()
|
||
export class SpecialPermissionGuard implements CanActivate {
|
||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||
|
||
canActivate(context: ExecutionContext): boolean {
|
||
if (isPublic(this.reflector, context)) {
|
||
return true;
|
||
}
|
||
const special = requiredPermissions(this.reflector, context).filter((code) =>
|
||
SPECIAL_PERMISSION_SET.has(code),
|
||
);
|
||
if (special.length === 0) {
|
||
return true;
|
||
}
|
||
const auth = requireAuth(context);
|
||
if (!special.some((code) => auth.permissions.includes(code))) {
|
||
throw new AppException({
|
||
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||
details: { required: special },
|
||
});
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 4) 数据范围守卫:解析 `ScopeContext`(六类范围 → 可见客户集合),注入 `request.scope`
|
||
* 与请求上下文;Repository 必须通过 `customerScopeWhere(ctx)` 强制客户维度过滤。
|
||
*
|
||
* 口径(技术架构 §5.5 / §7.1「失败处理」):解析失败一律 403(40302),不降级为「全部客户」;
|
||
* 公开接口(登录 / 健康检查)不解析。
|
||
*/
|
||
@Injectable()
|
||
export class ScopeGuard implements CanActivate {
|
||
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;
|
||
}
|
||
}
|