Files
lingxi/apps/api/src/common/security/guards.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

191 lines
6.7 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 { 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;
}
}