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

@@ -11,6 +11,7 @@ import { tap } from 'rxjs';
import { AUDIT_ACTION_KEY } from './decorators';
import {
type AuditDetail,
buildRequestContext,
type RequestContext,
RequestContextService,
@@ -26,7 +27,16 @@ interface AuditPayload {
result: 'success' | 'failure';
actorId: string | null;
account: string | null;
ip: string | null;
userAgent: string | null;
targetType: string | null;
targetId: string | null;
before?: unknown;
after?: unknown;
changes?: unknown;
affectedUserCount?: number;
affectedUserIds?: string[];
reason?: string;
traceId: string;
durationMs: number;
error?: string;
@@ -39,7 +49,10 @@ function actionOf(context: ExecutionContext): string | null {
/**
* 全局请求上下文 + @Audit 结构化日志。
* A1 先写日志(Pino);A5 审计表落地后在此追加落库,装饰器签名不变。
*
* A2 起角色 / 权限变更由业务 Service 通过 `RequestContextService.patchAudit` 写入
* 前后值、影响用户数等明细;本拦截器统一输出一条结构化 Pino 日志(含操作人 / IP / UA /
* trace_id),A5 审计表落地后在此追加落库(装饰器签名不变)。
*/
@Injectable()
export class AuditInterceptor implements NestInterceptor {
@@ -80,15 +93,27 @@ export class AuditInterceptor implements NestInterceptor {
ctx: RequestContext,
action: string,
result: AuditPayload['result'],
targetId: string | null,
defaultTargetId: string | null,
error?: unknown,
): void {
const detail: AuditDetail = ctx.audit ?? {};
const payload: AuditPayload = {
action,
result,
actorId: ctx.userId,
account: ctx.account,
targetId,
ip: ctx.ip,
userAgent: ctx.userAgent,
targetType: detail.targetType ?? null,
targetId: detail.targetId ?? defaultTargetId,
...(detail.before === undefined ? {} : { before: detail.before }),
...(detail.after === undefined ? {} : { after: detail.after }),
...(detail.changes === undefined ? {} : { changes: detail.changes }),
...(detail.affectedUserCount === undefined
? {}
: { affectedUserCount: detail.affectedUserCount }),
...(detail.affectedUserIds === undefined ? {} : { affectedUserIds: detail.affectedUserIds }),
...(detail.reason === undefined ? {} : { reason: detail.reason }),
traceId: ctx.traceId,
durationMs: Date.now() - ctx.startedAt,
...(error === undefined
+13 -1
View File
@@ -1,6 +1,8 @@
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
import type { FastifyRequest } from 'fastify';
import type { ScopeContext } from './scope/scope-context';
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
export interface JwtAccessPayload {
sub: string;
@@ -22,12 +24,22 @@ export interface AuthContext {
status: UserStatus;
roles: RoleSummary[];
permissions: string[];
dataScope: DataScope;
/**
* 角色声明的数据范围(未解析客户集合;多角色,每个角色一种类型)。
* 超管角色 → `[{ type: 'all' }]`(隐式全集)。
*/
dataScopes: DataScope[];
/** 用户所在部门 + 其下级部门 id(两级组织;用于解析部门类范围) */
departmentIds: string[];
/** 是否拥有 `is_system` 超管角色(隐式拥有全部权限点 / 特殊权限 / 数据范围) */
isSuperAdmin: boolean;
mustChangePassword: boolean;
}
export interface AuthenticatedRequest extends FastifyRequest {
auth?: AuthContext;
/** ScopeGuard 解析后的请求作用域(Repository 客户维度过滤的唯一来源) */
scope?: ScopeContext;
}
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */
@@ -0,0 +1,79 @@
import { PERMISSION_CATALOG, SPECIAL_PERMISSIONS } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { PermissionCatalogService } from './permission-catalog.service';
function makeService(): {
service: PermissionCatalogService;
upsert: ReturnType<typeof vi.fn>;
} {
const upsert = vi.fn().mockResolvedValue({});
const prisma = { permission: { upsert } };
return {
service: new PermissionCatalogService(prisma as never),
upsert,
};
}
describe('PermissionCatalogService', () => {
it('启动同步:30 个权限点逐个幂等 upsert(不删除历史行)', async () => {
const { service, upsert } = makeService();
const count = await service.sync();
expect(count).toBe(30);
expect(upsert).toHaveBeenCalledTimes(30);
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { code: 'role:view' },
}),
);
expect(upsert.mock.calls[0]?.[0]).toEqual({
where: { code: PERMISSION_CATALOG[0]?.code },
update: expect.objectContaining({ module: PERMISSION_CATALOG[0]?.module }),
create: expect.objectContaining({ code: PERMISSION_CATALOG[0]?.code }),
});
});
it('启动同步失败只告警不阻断(OpenAPI 导出 / 无 DB 命令可继续)', async () => {
const { service, upsert } = makeService();
upsert.mockRejectedValueOnce(new Error('db unavailable'));
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
});
it('权限树:10 模块(与 UI 原型顺序一致)+ 6 特殊权限 + 3 对 SoD', () => {
const { service } = makeService();
const tree = service.buildTree();
expect(tree.totalPermissions).toBe(30);
expect(tree.modules).toHaveLength(10);
expect(tree.modules.map((module) => module.module)).toEqual([
'script',
'material',
'asset',
'delivery',
'customer',
'customer_group',
'identity',
'industry',
'platform',
'audit',
]);
expect(tree.modules.map((module) => module.moduleLabel)).toEqual([
'脚本',
'素材',
'资产',
'导出交付',
'客户',
'客户组',
'组织与角色',
'行业包',
'平台配置',
'审计',
]);
expect(tree.modules.flatMap((module) => module.permissions)).toHaveLength(30);
expect(tree.specialPermissions).toHaveLength(6);
expect(tree.specialPermissions.map((item) => item.code).sort()).toEqual(
[...SPECIAL_PERMISSIONS].sort(),
);
expect(tree.sodPairs).toHaveLength(3);
expect(tree.sodPairs[0]?.permissions).toEqual(['script:write', 'script:review']);
});
});
@@ -0,0 +1,112 @@
import {
PERMISSION_CATALOG,
type PermissionModule,
permissionsByModule,
type PermissionTree,
SOD_PAIRS,
SPECIAL_PERMISSIONS,
} from '@lingxi/shared';
import { Inject, Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
import type { PrismaService } from '../../../infra/prisma/prisma.service';
import { PRISMA } from '../../../infra/tokens';
/**
* 权限点注册与同步(A2-BE-02)。
*
* 唯一事实源是 `@lingxi/shared` 的 `PERMISSION_CATALOG`:启动时把目录幂等 upsert 进
* `permissions` 表(只增不删),供管理端检索 / 审计 / 后续统计 join;权限树本身仍从
* 代码目录生成(避免「DB 里有、代码里没有」的幽灵权限点)。
*
* 启动同步失败只告警不阻断(OpenAPI 导出、无 DB 的本地命令不应因此失败);
* 生产健康检查与告警另行覆盖。
*/
@Injectable()
export class PermissionCatalogService implements OnApplicationBootstrap {
private readonly logger = new Logger('PermissionCatalog');
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async onApplicationBootstrap(): Promise<void> {
try {
const count = await this.sync();
this.logger.log(`权限点目录已同步:${String(count)} 个`);
} catch (error) {
this.logger.warn(
`权限点目录同步失败(不阻断启动):${error instanceof Error ? error.message : '未知错误'}`,
);
}
}
/** 幂等 upsert:更新元数据、补新增,不删除目录之外的历史行(只增不删) */
async sync(): Promise<number> {
for (const item of PERMISSION_CATALOG) {
await this.prisma.permission.upsert({
where: { code: item.code },
update: {
module: item.module,
moduleLabel: item.moduleLabel,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
sortOrder: item.sortOrder,
},
create: {
code: item.code,
module: item.module,
moduleLabel: item.moduleLabel,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
sortOrder: item.sortOrder,
},
});
}
return PERMISSION_CATALOG.length;
}
/** 权限树(10 模块 + 特殊权限汇总 + SoD 对):直接由代码目录生成 */
buildTree(): PermissionTree {
const modules: {
module: PermissionModule;
moduleLabel: string;
sortOrder: number;
permissions: {
code: string;
name: string;
description: string;
isSpecial: boolean;
}[];
}[] = permissionsByModule().map((group) => ({
module: group.module,
moduleLabel: group.moduleLabel,
sortOrder: group.sortOrder,
permissions: group.permissions.map((item) => ({
code: item.code,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
})),
}));
const specialCodeSet = new Set<string>(SPECIAL_PERMISSIONS);
const specialPermissions = PERMISSION_CATALOG.filter((item) =>
specialCodeSet.has(item.code),
).map((item) => ({
code: item.code,
name: item.name,
description: item.description,
isSpecial: true,
}));
return {
modules,
specialPermissions,
sodPairs: SOD_PAIRS.map((pair) => ({
id: pair.id,
label: pair.label,
description: pair.description,
permissions: [pair.permissions[0], pair.permissions[1]],
})),
totalPermissions: PERMISSION_CATALOG.length,
};
}
}
@@ -1,6 +1,7 @@
import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common';
import type { AuthContext, AuthenticatedRequest } from './auth.types';
import type { ScopeContext } from './scope/scope-context';
export const IS_PUBLIC_KEY = 'auth:public';
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
@@ -23,3 +24,11 @@ export const CurrentUser = createParamDecorator(
return request.auth ?? null;
},
);
/** 取 ScopeGuard 解析后的数据范围上下文;未登录 / 未解析返回 null */
export const CurrentScope = createParamDecorator(
(_data: unknown, context: ExecutionContext): ScopeContext | null => {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
return request.scope ?? null;
},
);
+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;
}
}
@@ -1,4 +1,12 @@
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
import {
DATA_SCOPE_TYPES,
type DataScope,
type DataScopeType,
isPermissionCode,
PERMISSION_CODES,
type RoleSummary,
type UserStatus,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
@@ -6,17 +14,19 @@ import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import type { AuthContext } from './auth.types';
/** 鉴权解析需要的用户关联:部门(含父级)、角色(含权限点) */
/** 鉴权解析需要的用户关联:部门(父级 + 下级)、角色(含权限点与数据范围声明) */
export type UserAuthRecord = Prisma.UserGetPayload<{
include: {
department: { include: { parent: true } };
roles: { include: { role: { include: { permissions: true } } } };
department: { include: { parent: true; children: true } };
roles: {
include: { role: { include: { permissions: true; dataScopes: true } } };
};
};
}>;
const ALL_DATA_SCOPE_ROLES: readonly string[] = ['super_admin', 'platform_admin'];
type RoleEntity = UserAuthRecord['roles'][number]['role'];
function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSummary {
function toRoleSummary(role: RoleEntity): RoleSummary {
return {
id: role.id,
code: role.code,
@@ -26,32 +36,59 @@ function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSumma
};
}
/** 数据范围骨架:A1 只分「全部 / 本部门及下级」,A2 再落六类范围与客户集合解析 */
export function resolveDataScope(roleCodes: readonly string[]): DataScope {
const isGlobal = roleCodes.some((code) => ALL_DATA_SCOPE_ROLES.includes(code));
return isGlobal ? { type: 'all' } : { type: 'department_and_children' };
}
export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] {
return user.roles.filter((item) => item.role.status === 'ACTIVE');
}
/**
* 合并多角色的数据范围声明:同一类型取并集(targetIds 去重排序),按六类固定顺序输出。
* 一个角色只允许一种类型(A2 契约),但历史数据可能有多行(customer_groups 多客户组),
* 因此按 scope_type 聚合后再跨角色合并。
*/
export function mergeRoleDataScopes(roles: readonly RoleEntity[]): DataScope[] {
const byType = new Map<DataScopeType, Set<string>>();
for (const role of roles) {
for (const scope of role.dataScopes) {
const type = scope.scopeType as DataScopeType;
const targets = byType.get(type) ?? new Set<string>();
if (scope.targetId !== null) {
targets.add(scope.targetId);
}
byType.set(type, targets);
}
}
const ordered: DataScope[] = [];
for (const type of DATA_SCOPE_TYPES) {
const targets = byType.get(type);
if (targets === undefined) {
continue;
}
ordered.push({
type,
...(type === 'customer_groups' ? { targetIds: [...targets].sort() } : {}),
});
}
return ordered;
}
/** 用户部门子树 id(自身在前,最多两级):scope 解析需要「本部门 / 本部门及下级」 */
export function departmentSubtreeIds(user: UserAuthRecord): string[] {
return [user.department.id, ...user.department.children.map((child) => child.id)];
}
/**
* 权限解析:**每个请求实时读 DB**,不缓存角色/权限点,
* 保证角色变更在下一次请求立即生效(A1-BE-04 验收项)。
* 保证角色变更(含停用)在下一次请求立即生效(A1-BE-04 / A2-BE-05 验收项)。
*
* 超管(`is_system = true`)隐式拥有全部权限点与特殊权限,`role_permissions` 不参与解析;
* 其余角色取启用角色权限点并集,未登记编码被丢弃(防「幽灵权限点」)。
*/
@Injectable()
export class PermissionService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async resolveAuth(userId: string): Promise<AuthContext | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true } },
roles: { include: { role: { include: { permissions: true } } } },
},
});
const user = await this.findAuthRecord(userId);
if (user === null) {
return null;
}
@@ -62,25 +99,35 @@ export class PermissionService {
return this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true } },
roles: { include: { role: { include: { permissions: true } } } },
department: { include: { parent: true, children: true } },
roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
},
});
}
toAuthContext(user: UserAuthRecord): AuthContext {
const roles = activeRolesOf(user);
const permissions = [
...new Set(roles.flatMap((item) => item.role.permissions.map((p) => p.permissionCode))),
].sort();
const roleEntities = roles.map((item) => item.role);
const isSuperAdmin = roleEntities.some((role) => role.isSystem);
const permissions = isSuperAdmin
? [...PERMISSION_CODES]
: [
...new Set(
roleEntities
.flatMap((role) => role.permissions.map((item) => item.permissionCode))
.filter((code) => isPermissionCode(code)),
),
].sort();
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
return {
userId: user.id,
account: user.account,
status,
roles: roles.map((item) => toRoleSummary(item.role)),
roles: roleEntities.map((role) => toRoleSummary(role)),
permissions,
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
dataScopes: isSuperAdmin ? [{ type: 'all' }] : mergeRoleDataScopes(roleEntities),
departmentIds: departmentSubtreeIds(user),
isSuperAdmin,
mustChangePassword: user.mustChangePassword,
};
}
@@ -4,13 +4,35 @@ import { ulid } from '@lingxi/shared';
import { Injectable } from '@nestjs/common';
import type { AuthenticatedRequest } from './auth.types';
import type { ScopeContext } from './scope/scope-context';
/** 单次请求上下文:trace_id / 当前用户 / 权限快照(@Audit 与业务日志复用) */
/**
* 变更审计明细(A2-BE-05):由业务 Service 通过 `RequestContextService.patch({ audit })` 写入,
* AuditInterceptor 在请求结束时统一输出结构化 Pino 日志(A5 再落审计表)。
*
* 权限 / 角色类变更必须带:操作人(context.userId)、IP(context.ip)、
* 变更前后值(before / after)、影响用户数(affectedUserCount / affectedUserIds)。
*/
export interface AuditDetail {
targetType?: string;
targetId?: string;
before?: unknown;
after?: unknown;
affectedUserCount?: number;
affectedUserIds?: string[];
reason?: string;
/** 追加的变更摘要(如权限点 diff),与 before / after 并存 */
changes?: unknown;
}
/** 单次请求上下文:trace_id / 当前用户 / 权限快照 / 数据范围 / 审计明细 */
export interface RequestContext {
traceId: string;
userId: string | null;
account: string | null;
permissions: string[] | null;
scope: ScopeContext | null;
audit: AuditDetail | null;
ip: string | null;
userAgent: string | null;
method: string;
@@ -32,6 +54,8 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
userId: auth?.userId ?? null,
account: auth?.account ?? null,
permissions: auth?.permissions ?? null,
scope: request.scope ?? null,
audit: null,
ip: request.ip.length > 0 ? request.ip : null,
userAgent: request.headers['user-agent'] ?? null,
method: request.method,
@@ -42,7 +66,7 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
/**
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
* 业务 Service 可 `get()` 读取 trace_id / 当前用户,无需逐层透传。
* 业务 Service 可 `get()` 读取 trace_id / 当前用户 / ScopeContext,无需逐层透传。
*/
@Injectable()
export class RequestContextService {
@@ -63,4 +87,13 @@ export class RequestContextService {
}
Object.assign(store, partial);
}
/** 追加审计明细(多次 patch 时浅合并,便于业务 Service 逐步补充) */
patchAudit(detail: Partial<AuditDetail>): void {
const store = this.storage.getStore();
if (store === undefined) {
return;
}
store.audit = { ...(store.audit ?? {}), ...detail };
}
}
@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
/**
* 客户目录端口(A2-BE-04 / 技术架构 §5.5)。
*
* 数据范围的可见客户集合由「客户目录」解析;A2 只有角色与范围声明,客户 / 客户组 /
* 客户负责人属 A3(client 模块)。为避免 A2 依赖未建表的客户模块,这里定义可替换端口:
* - A2 注册 `EmptyCustomerDirectory`:所有方法返回空集(不越权,不需要降级);
* - A3 落地后用真实实现替换 provider(注入 token 不变),ScopeResolver 无需修改。
*
* 约定:端口只读;返回客户 id 列表,由 ScopeResolver 做去重与并集。
*/
export const CUSTOMER_DIRECTORY = Symbol('CUSTOMER_DIRECTORY');
export interface CustomerDirectory {
/** 「负责客户」范围:客户负责人 = 本人 */
findOwnedCustomerIds(userId: string): Promise<string[]>;
/** 「指定客户组」范围:客户组下全部客户 */
findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]>;
/** 「本部门 / 本部门及下级」范围:部门子树内负责人所属客户 */
findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]>;
/** 「本人创建」范围:创建人 = 本人 */
findCreatedCustomerIds(userId: string): Promise<string[]>;
}
/**
* A2 占位实现:A3 前没有任何客户数据,所有范围解析为空集。
* 空集意味着「看不到任何客户」,是安全的默认值(解析失败宁可不可用也不越权)。
*/
@Injectable()
export class EmptyCustomerDirectory implements CustomerDirectory {
findOwnedCustomerIds(_userId: string): Promise<string[]> {
return Promise.resolve([]);
}
findCustomerGroupCustomerIds(_customerGroupIds: readonly string[]): Promise<string[]> {
return Promise.resolve([]);
}
findDepartmentCustomerIds(_departmentIds: readonly string[]): Promise<string[]> {
return Promise.resolve([]);
}
findCreatedCustomerIds(_userId: string): Promise<string[]> {
return Promise.resolve([]);
}
}
@@ -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;
}
@@ -0,0 +1,135 @@
import { DATA_SCOPE_TYPES, type DataScope, type DataScopeType, ErrorCode } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../errors/app-exception';
import type { AuthContext } from '../auth.types';
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
import type { ScopeContext } from './scope-context';
/** 范围声明去重后的内部结构:一个角色一种类型,但多角色可能带来同类型的多个 targetIds */
interface ScopeDeclaration {
type: DataScopeType;
targetIds: string[];
}
function mergeDeclarations(scopes: readonly DataScope[]): ScopeDeclaration[] {
const byType = new Map<DataScopeType, Set<string>>();
for (const scope of scopes) {
const existing = byType.get(scope.type) ?? new Set<string>();
for (const targetId of scope.targetIds ?? []) {
existing.add(targetId);
}
byType.set(scope.type, existing);
}
return [...byType.entries()]
.map(([type, targetIdSet]) => ({
type,
targetIds: [...targetIdSet].sort(),
}))
.sort(
(left, right) => DATA_SCOPE_TYPES.indexOf(left.type) - DATA_SCOPE_TYPES.indexOf(right.type),
);
}
function uniqueSorted(ids: readonly string[]): string[] {
return [...new Set(ids)].sort();
}
/**
* 数据范围解析器(A2-BE-04):把角色声明的六类范围解析为 `ScopeContext`。
*
* - 超管(is_system 角色)隐式全集:`[{ type: 'all' }]`;
* - 任一角色为「全部客户」→ 并集为全部客户;
* - 多个角色取并集(customerIds 去重排序);
* - 没有任何范围声明时按最小权限「本人创建」解析,不放行全部;
* - 端口调用失败 → 40302(不降级)。
*/
@Injectable()
export class ScopeResolver {
constructor(@Inject(CUSTOMER_DIRECTORY) private readonly customers: CustomerDirectory) {}
async resolve(
auth: Pick<AuthContext, 'userId' | 'isSuperAdmin' | 'dataScopes' | 'departmentIds'>,
): Promise<ScopeContext> {
if (auth.isSuperAdmin) {
return {
userId: auth.userId,
isSuperAdmin: true,
dataScopes: [{ type: 'all' }],
customerIds: null,
};
}
const declarations =
mergeDeclarations(auth.dataScopes).length > 0
? mergeDeclarations(auth.dataScopes)
: [{ type: 'created_by_me' as const, targetIds: [] }];
if (declarations.some((declaration) => declaration.type === 'all')) {
return {
userId: auth.userId,
isSuperAdmin: false,
dataScopes: [{ type: 'all' }],
customerIds: null,
};
}
try {
return await this.buildScopedContext(auth, declarations);
} catch (error) {
if (error instanceof AppException) {
throw error;
}
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '数据范围解析失败,已拒绝访问',
});
}
}
private async buildScopedContext(
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
declarations: readonly ScopeDeclaration[],
): Promise<ScopeContext> {
const union = new Set<string>();
const resolved: DataScope[] = [];
for (const declaration of declarations) {
const ids = uniqueSorted(await this.customerIdsFor(declaration, auth));
for (const id of ids) {
union.add(id);
}
resolved.push({
type: declaration.type,
...(declaration.targetIds.length > 0 ? { targetIds: declaration.targetIds } : {}),
customerIds: ids,
});
}
return {
userId: auth.userId,
isSuperAdmin: false,
dataScopes: resolved,
customerIds: [...union].sort(),
};
}
private async customerIdsFor(
declaration: ScopeDeclaration,
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
): Promise<string[]> {
switch (declaration.type) {
case 'all':
return [];
case 'customer_groups':
return this.customers.findCustomerGroupCustomerIds(declaration.targetIds);
case 'owned_customers':
return this.customers.findOwnedCustomerIds(auth.userId);
case 'created_by_me':
return this.customers.findCreatedCustomerIds(auth.userId);
case 'department': {
const ownDepartmentId = auth.departmentIds[0];
return ownDepartmentId === undefined
? []
: this.customers.findDepartmentCustomerIds([ownDepartmentId]);
}
case 'department_and_children':
return this.customers.findDepartmentCustomerIds(auth.departmentIds);
}
}
}
@@ -0,0 +1,189 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../errors/app-exception';
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
import {
assertCustomerAccess,
customerScopeWhere,
isAllCustomers,
isCustomerVisible,
requireScopeContext,
type ScopeContext,
} from './scope-context';
import { ScopeResolver } from './scope-resolver.service';
function directoryMock(): {
directory: CustomerDirectory;
owned: ReturnType<typeof vi.fn>;
groups: ReturnType<typeof vi.fn>;
departments: ReturnType<typeof vi.fn>;
created: ReturnType<typeof vi.fn>;
} {
const owned = vi.fn().mockResolvedValue(['c-owned']);
const groups = vi.fn().mockResolvedValue(['c-group-1']);
const departments = vi.fn().mockResolvedValue(['c-dept']);
const created = vi.fn().mockResolvedValue(['c-created']);
return {
directory: {
findOwnedCustomerIds: owned,
findCustomerGroupCustomerIds: groups,
findDepartmentCustomerIds: departments,
findCreatedCustomerIds: created,
},
owned,
groups,
departments,
created,
};
}
function contextOf(overrides: Partial<ScopeContext> = {}): ScopeContext {
return {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [],
customerIds: [],
...overrides,
};
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('ScopeResolver', () => {
const baseAuth = {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [],
departmentIds: ['d1', 'd1-child'],
};
it('超管隐式全集:[{ type: all }],不调用客户目录', async () => {
const { directory, owned } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({ ...baseAuth, isSuperAdmin: true });
expect(scope.customerIds).toBeNull();
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
expect(owned).not.toHaveBeenCalled();
});
it('任一角色为全部客户 → 并集为全部客户', async () => {
const { directory } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({
...baseAuth,
dataScopes: [{ type: 'owned_customers' }, { type: 'all' }],
});
expect(scope.customerIds).toBeNull();
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
});
it('多角色并集:同一类型 targetIds 合并、customerIds 去重排序、按六类固定顺序输出', async () => {
const { directory, groups, departments } = directoryMock();
departments.mockResolvedValue(['c-dept', 'c-dept-2']);
groups.mockResolvedValue(['c-group-1', 'c-group-2']);
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({
...baseAuth,
dataScopes: [
{ type: 'customer_groups', targetIds: ['g2'] },
{ type: 'department_and_children' },
{ type: 'customer_groups', targetIds: ['g1'] },
],
});
expect(scope.customerIds).toEqual(['c-dept', 'c-dept-2', 'c-group-1', 'c-group-2']);
expect(scope.dataScopes).toEqual([
{ type: 'department_and_children', customerIds: ['c-dept', 'c-dept-2'] },
{
type: 'customer_groups',
targetIds: ['g1', 'g2'],
customerIds: ['c-group-1', 'c-group-2'],
},
]);
expect(departments).toHaveBeenCalledWith(['d1', 'd1-child']);
});
it('「本部门」只用用户本部门;「指定客户组」透传 targetIds;「负责客户」「本人创建」按用户解析', async () => {
const { directory, groups, departments, owned, created } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({
...baseAuth,
dataScopes: [{ type: 'department' }, { type: 'customer_groups', targetIds: ['g1'] }],
});
expect(departments).toHaveBeenCalledWith(['d1']);
expect(groups).toHaveBeenCalledWith(['g1']);
expect(scope.dataScopes[0]?.type).toBe('department');
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] });
expect(owned).toHaveBeenCalledWith('u1');
created.mockResolvedValue([]);
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'created_by_me' }] });
expect(created).toHaveBeenCalledWith('u1');
});
it('没有任何范围声明 → 按最小权限「本人创建」解析,不分全量', async () => {
const { directory } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({ ...baseAuth, dataScopes: [] });
expect(scope.customerIds).toEqual(['c-created']);
expect(scope.dataScopes[0]?.type).toBe('created_by_me');
});
it('客户目录端口异常 → 40302,不降级为全部客户', async () => {
const { directory, owned } = directoryMock();
owned.mockRejectedValueOnce(new Error('db down'));
const resolver = new ScopeResolver(directory);
await expectRejected(
resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] }),
ErrorCode.OUT_OF_SCOPE,
);
});
});
describe('scope-context 帮助函数', () => {
it('customerScopeWhere:全部客户返回空片段,受限范围返回 customerId IN', () => {
expect(customerScopeWhere(contextOf({ customerIds: null }))).toEqual({});
expect(customerScopeWhere(contextOf({ customerIds: ['c1', 'c2'] }))).toEqual({
customerId: { in: ['c1', 'c2'] },
});
});
it('isAllCustomers / isCustomerVisible', () => {
expect(isAllCustomers(contextOf({ customerIds: null }))).toBe(true);
expect(isAllCustomers(contextOf())).toBe(false);
expect(isCustomerVisible(contextOf({ customerIds: null }), 'any')).toBe(true);
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c1')).toBe(true);
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c2')).toBe(false);
});
it('assertCustomerAccess:范围外抛 40302;requireScopeContext 缺上下文抛 40302', () => {
expect(() => {
assertCustomerAccess(contextOf({ customerIds: null }), 'c1');
}).not.toThrow();
expect(() => {
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c1');
}).not.toThrow();
let thrown: unknown;
try {
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c2');
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AppException);
expect((thrown as AppException).code).toBe(ErrorCode.OUT_OF_SCOPE);
expect(() => requireScopeContext(null)).toThrow(AppException);
expect(requireScopeContext(contextOf()).userId).toBe('u1');
});
it('CUSTOMER_DIRECTORY 端口 token 唯一', () => {
expect(typeof CUSTOMER_DIRECTORY).toBe('symbol');
});
});
@@ -3,14 +3,20 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { AuditInterceptor } from './audit.interceptor';
import { PermissionCatalogService } from './catalog/permission-catalog.service';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import { PermissionService } from './permission.service';
import { RequestContextService } from './request-context';
import { CUSTOMER_DIRECTORY, EmptyCustomerDirectory } from './scope/customer-directory';
import { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service';
/**
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、JWT 与守卫链。
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
*
* `CUSTOMER_DIRECTORY` 是客户目录端口:A2 注册空集实现,A3 落地 client 模块后替换 provider
* 即可让六类数据范围解析出真实客户集合,ScopeResolver / 守卫链无需改动。
*/
@Global()
@Module({
@@ -18,18 +24,27 @@ import { TokenService } from './token.service';
providers: [
RequestContextService,
PermissionService,
PermissionCatalogService,
PermissionGuard,
ScopeResolver,
TokenService,
AuditInterceptor,
JwtAuthGuard,
PermissionGuard,
SpecialPermissionGuard,
ScopeGuard,
{ provide: CUSTOMER_DIRECTORY, useClass: EmptyCustomerDirectory },
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
{ provide: APP_GUARD, useExisting: PermissionGuard },
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
{ provide: APP_GUARD, useExisting: ScopeGuard },
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
],
exports: [RequestContextService, PermissionService, TokenService],
exports: [
RequestContextService,
PermissionService,
PermissionCatalogService,
ScopeResolver,
TokenService,
],
})
export class SecurityModule {}
+46 -13
View File
@@ -11,8 +11,10 @@ import type { AuthContext, AuthenticatedRequest } from './auth.types';
import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import type { UserAuthRecord } from './permission.service';
import { PermissionService, resolveDataScope } from './permission.service';
import { PermissionService } from './permission.service';
import type { RequestContextService } from './request-context';
import type { ScopeContext } from './scope/scope-context';
import type { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service';
const jwt = new JwtService({});
@@ -41,7 +43,9 @@ function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
status: 'active',
roles: [],
permissions: ['user:view'],
dataScope: { type: 'department_and_children' },
dataScopes: [{ type: 'department_and_children' }],
departmentIds: ['d1'],
isSuperAdmin: false,
mustChangePassword: false,
...overrides,
};
@@ -110,6 +114,7 @@ function authRecord(
createdAt: new Date(),
updatedAt: new Date(),
},
children: [],
},
roles: roleCodes.map((code, index) => ({
userId: 'u1',
@@ -128,6 +133,7 @@ function authRecord(
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
],
dataScopes: [],
},
})),
};
@@ -170,19 +176,31 @@ describe('TokenService', () => {
});
});
describe('PermissionService / resolveDataScope', () => {
it('数据范围:平台 / 超管为 all,其余为本部门及下级', () => {
expect(resolveDataScope(['super_admin']).type).toBe('all');
expect(resolveDataScope(['designer']).type).toBe('department_and_children');
});
describe('PermissionService', () => {
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
const service = new PermissionService({} as never);
const context = service.toAuthContext(authRecord());
expect(context.permissions).toEqual(['user:manage', 'user:view']);
expect(context.status).toBe('active');
expect(context.roles).toHaveLength(1);
expect(context.dataScope.type).toBe('all');
expect(context.isSuperAdmin).toBe(false);
expect(context.dataScopes).toEqual([]);
expect(context.departmentIds).toEqual(['d1']);
});
it('超管隐式全集:权限点 / 特殊权限不依赖 role_permissions', () => {
const record = authRecord('ACTIVE', ['super_admin']);
const role = record.roles[0];
if (role === undefined) {
throw new Error('fixture 缺少角色');
}
role.role.permissions = [];
const service = new PermissionService({} as never);
const context = service.toAuthContext(record);
expect(context.isSuperAdmin).toBe(true);
expect(context.permissions).toHaveLength(30);
expect(context.permissions).toContain('audit:export');
expect(context.dataScopes).toEqual([{ type: 'all' }]);
});
it('resolveAuth 用户不存在返回 null', async () => {
@@ -198,7 +216,7 @@ describe('PermissionService / resolveDataScope', () => {
const service = new PermissionService(prisma as never);
const context = await service.resolveAuth('u1');
expect(context?.userId).toBe('u1');
expect(context?.dataScope.type).toBe('department_and_children');
expect(context?.isSuperAdmin).toBe(false);
expect(context?.roles[0]?.code).toBe('designer');
});
});
@@ -299,11 +317,26 @@ describe('PermissionGuard / SpecialPermissionGuard / ScopeGuard', () => {
expect(granted.canActivate(contextOf(request))).toBe(true);
});
it('未登录时普通守卫返回 40101;ScopeGuard 骨架放行', () => {
it('未登录时普通守卫返回 40101;ScopeGuard 解析并注入 scope', async () => {
const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never);
expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED);
expect(new ScopeGuard().canActivate(contextOf(request))).toBe(true);
expect(new ScopeGuard().canActivate(contextOf({}))).toBe(true);
const scope: ScopeContext = {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [{ type: 'owned_customers', customerIds: [] }],
customerIds: [],
};
const resolver = { resolve: vi.fn().mockResolvedValue(scope) } as unknown as ScopeResolver;
const scopeGuard = new ScopeGuard(reflectorFor({}) as never, resolver, {
patch: vi.fn(),
} as unknown as RequestContextService);
const scopedRequest = { auth } as AuthenticatedRequest;
await expect(scopeGuard.canActivate(contextOf(scopedRequest))).resolves.toBe(true);
expect(scopedRequest.scope).toEqual(scope);
// 未登录(无 auth)直接放行,由 JwtAuthGuard 负责 401
await expect(scopeGuard.canActivate(contextOf({}))).resolves.toBe(true);
});
it('@Audit 元数据可读取', () => {