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

@@ -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');
});
});