diff --git a/apps/api/src/common/security/audit.interceptor.ts b/apps/api/src/common/security/audit.interceptor.ts index eaa070e..a378c86 100644 --- a/apps/api/src/common/security/audit.interceptor.ts +++ b/apps/api/src/common/security/audit.interceptor.ts @@ -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 diff --git a/apps/api/src/common/security/auth.types.ts b/apps/api/src/common/security/auth.types.ts index d77d24f..37d8242 100644 --- a/apps/api/src/common/security/auth.types.ts +++ b/apps/api/src/common/security/auth.types.ts @@ -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) */ diff --git a/apps/api/src/common/security/catalog/permission-catalog.service.spec.ts b/apps/api/src/common/security/catalog/permission-catalog.service.spec.ts new file mode 100644 index 0000000..3956906 --- /dev/null +++ b/apps/api/src/common/security/catalog/permission-catalog.service.spec.ts @@ -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; +} { + 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']); + }); +}); diff --git a/apps/api/src/common/security/catalog/permission-catalog.service.ts b/apps/api/src/common/security/catalog/permission-catalog.service.ts new file mode 100644 index 0000000..5bd3210 --- /dev/null +++ b/apps/api/src/common/security/catalog/permission-catalog.service.ts @@ -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 { + 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 { + 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(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, + }; + } +} diff --git a/apps/api/src/common/security/decorators.ts b/apps/api/src/common/security/decorators.ts index 9ba1ea3..404f88d 100644 --- a/apps/api/src/common/security/decorators.ts +++ b/apps/api/src/common/security/decorators.ts @@ -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(); + return request.scope ?? null; + }, +); diff --git a/apps/api/src/common/security/guards.ts b/apps/api/src/common/security/guards.ts index 04c433b..0e4009c 100644 --- a/apps/api/src/common/security/guards.ts +++ b/apps/api/src/common/security/guards.ts @@ -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 { + if (isPublic(this.reflector, context)) { + return true; } + const request = context.switchToHttp().getRequest(); + 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; } } diff --git a/apps/api/src/common/security/permission.service.ts b/apps/api/src/common/security/permission.service.ts index 85a65c8..9dce164 100644 --- a/apps/api/src/common/security/permission.service.ts +++ b/apps/api/src/common/security/permission.service.ts @@ -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>(); + for (const role of roles) { + for (const scope of role.dataScopes) { + const type = scope.scopeType as DataScopeType; + const targets = byType.get(type) ?? new Set(); + 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 { - 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, }; } diff --git a/apps/api/src/common/security/request-context.ts b/apps/api/src/common/security/request-context.ts index 4ce959e..cb27b43 100644 --- a/apps/api/src/common/security/request-context.ts +++ b/apps/api/src/common/security/request-context.ts @@ -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): void { + const store = this.storage.getStore(); + if (store === undefined) { + return; + } + store.audit = { ...(store.audit ?? {}), ...detail }; + } } diff --git a/apps/api/src/common/security/scope/customer-directory.ts b/apps/api/src/common/security/scope/customer-directory.ts new file mode 100644 index 0000000..fe0d636 --- /dev/null +++ b/apps/api/src/common/security/scope/customer-directory.ts @@ -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; + /** 「指定客户组」范围:客户组下全部客户 */ + findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise; + /** 「本部门 / 本部门及下级」范围:部门子树内负责人所属客户 */ + findDepartmentCustomerIds(departmentIds: readonly string[]): Promise; + /** 「本人创建」范围:创建人 = 本人 */ + findCreatedCustomerIds(userId: string): Promise; +} + +/** + * A2 占位实现:A3 前没有任何客户数据,所有范围解析为空集。 + * 空集意味着「看不到任何客户」,是安全的默认值(解析失败宁可不可用也不越权)。 + */ +@Injectable() +export class EmptyCustomerDirectory implements CustomerDirectory { + findOwnedCustomerIds(_userId: string): Promise { + return Promise.resolve([]); + } + + findCustomerGroupCustomerIds(_customerGroupIds: readonly string[]): Promise { + return Promise.resolve([]); + } + + findDepartmentCustomerIds(_departmentIds: readonly string[]): Promise { + return Promise.resolve([]); + } + + findCreatedCustomerIds(_userId: string): Promise { + return Promise.resolve([]); + } +} diff --git a/apps/api/src/common/security/scope/scope-context.ts b/apps/api/src/common/security/scope/scope-context.ts new file mode 100644 index 0000000..987e2ca --- /dev/null +++ b/apps/api/src/common/security/scope/scope-context.ts @@ -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; +} diff --git a/apps/api/src/common/security/scope/scope-resolver.service.ts b/apps/api/src/common/security/scope/scope-resolver.service.ts new file mode 100644 index 0000000..cd6747b --- /dev/null +++ b/apps/api/src/common/security/scope/scope-resolver.service.ts @@ -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>(); + for (const scope of scopes) { + const existing = byType.get(scope.type) ?? new Set(); + 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, + ): Promise { + 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, + declarations: readonly ScopeDeclaration[], + ): Promise { + const union = new Set(); + 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, + ): Promise { + 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); + } + } +} diff --git a/apps/api/src/common/security/scope/scope.spec.ts b/apps/api/src/common/security/scope/scope.spec.ts new file mode 100644 index 0000000..93ba997 --- /dev/null +++ b/apps/api/src/common/security/scope/scope.spec.ts @@ -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; + groups: ReturnType; + departments: ReturnType; + created: ReturnType; +} { + 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 { + return { + userId: 'u1', + isSuperAdmin: false, + dataScopes: [], + customerIds: [], + ...overrides, + }; +} + +function expectRejected(promise: Promise, code: ErrorCode): Promise { + 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'); + }); +}); diff --git a/apps/api/src/common/security/security.module.ts b/apps/api/src/common/security/security.module.ts index 20f37cd..87f769a 100644 --- a/apps/api/src/common/security/security.module.ts +++ b/apps/api/src/common/security/security.module.ts @@ -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 {} diff --git a/apps/api/src/common/security/security.spec.ts b/apps/api/src/common/security/security.spec.ts index 6614488..207021d 100644 --- a/apps/api/src/common/security/security.spec.ts +++ b/apps/api/src/common/security/security.spec.ts @@ -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 { 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 元数据可读取', () => { diff --git a/apps/api/src/modules/identity/auth.service.spec.ts b/apps/api/src/modules/identity/auth.service.spec.ts index 7af1fb6..69c2b85 100644 --- a/apps/api/src/modules/identity/auth.service.spec.ts +++ b/apps/api/src/modules/identity/auth.service.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it, type Mock, vi } from 'vitest'; import { AppException } from '../../common/errors/app-exception'; import type { PermissionService } from '../../common/security/permission.service'; +import type { ScopeResolver } from '../../common/security/scope/scope-resolver.service'; import { TokenService } from '../../common/security/token.service'; import type { PrismaService } from '../../infra/prisma/prisma.service'; import { testConfig } from '../../testing/test-app'; @@ -102,16 +103,19 @@ function harness(): Harness { status: 'active', roles: [], permissions: ['user:manage'], - dataScope: { type: 'all' }, + dataScopes: [{ type: 'all' }], + departmentIds: ['d1'], + isSuperAdmin: true, mustChangePassword: false, }), }; + const scopes = { resolve: vi.fn().mockResolvedValue({ dataScopes: [{ type: 'all' }] }) }; const me = { getMe: vi.fn().mockResolvedValue({ user: { id: 'u1' }, roles: [], permissions: [], - dataScope: { type: 'all' }, + dataScopes: [{ type: 'all' }], mustChangePassword: false, }), }; @@ -122,6 +126,7 @@ function harness(): Harness { passwords, failures as unknown as LoginFailureService, permissions as unknown as PermissionService, + scopes as unknown as ScopeResolver, me as unknown as MeService, ); return { service, prisma, failures, passwords, permissions, me }; diff --git a/apps/api/src/modules/identity/auth.service.ts b/apps/api/src/modules/identity/auth.service.ts index 94d228f..4f44cf8 100644 --- a/apps/api/src/modules/identity/auth.service.ts +++ b/apps/api/src/modules/identity/auth.service.ts @@ -14,6 +14,7 @@ import type { RefreshToken, User } from '@prisma/client'; import { AppException } from '../../common/errors/app-exception'; import type { AuthRequestMeta } from '../../common/security/auth.types'; import { PermissionService } from '../../common/security/permission.service'; +import { ScopeResolver } from '../../common/security/scope/scope-resolver.service'; import { TokenService } from '../../common/security/token.service'; import { APP_CONFIG } from '../../config/config.module'; import type { AppConfig } from '../../config/env'; @@ -33,6 +34,7 @@ export class AuthService { @Inject(PasswordService) private readonly passwords: PasswordService, @Inject(LoginFailureService) private readonly failures: LoginFailureService, @Inject(PermissionService) private readonly permissions: PermissionService, + @Inject(ScopeResolver) private readonly scopes: ScopeResolver, @Inject(MeService) private readonly me: MeService, ) {} @@ -61,8 +63,12 @@ export class AuthService { data: { lastLoginAt: new Date(), lastLoginIp: meta.ip }, }); const auth = await this.permissions.resolveAuth(user.id); + if (auth === null) { + throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' }); + } + const scope = await this.scopes.resolve(auth); const { pair } = await this.createTokenPair(user.id, null, meta); - const me = await this.me.getMe(user.id, auth?.permissions ?? []); + const me = await this.me.getMe(user.id, auth.permissions, scope); return { ...pair, me }; } diff --git a/apps/api/src/modules/identity/identity.int.spec.ts b/apps/api/src/modules/identity/identity.int.spec.ts index 4c7c337..1b90485 100644 --- a/apps/api/src/modules/identity/identity.int.spec.ts +++ b/apps/api/src/modules/identity/identity.int.spec.ts @@ -19,11 +19,18 @@ interface FixtureUser { account: string; } +interface MeBody { + permissions: string[]; + dataScopes: { type: string; customerIds?: string[] }[]; + user: { id: string; phone?: string }; + mustChangePassword: boolean; +} + interface TokenBody { accessToken: string; refreshToken: string; expiresIn: number; - me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean }; + me: MeBody; } interface ErrorBody { @@ -88,6 +95,87 @@ async function setRole(userId: string, roleCode: string): Promise { await prisma.userRole.create({ data: { userId, roleId: role.id } }); } +interface JsonRequestResult { + status: number; + body: T; +} + +async function callJson( + app: CreatedApiApp['app'], + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + url: string, + token: string, + payload?: unknown, +): Promise> { + const response = await app.inject({ + method, + url: `${API}${url}`, + headers: { authorization: `Bearer ${token}` }, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + return { status: response.statusCode, body: response.json() }; +} + +const ROLE_NAME_PREFIX = 'IT-角色-'; + +/** 集成测试创建的角色 id:afterAll 清理 outbox 占位事件,避免污染本地库 */ +const createdRoleIds: string[] = []; + +/** 集成测试专用角色(名称前缀隔离,afterAll 清理;删除 cascades user_roles / role_permissions) */ +async function createTestRole(input: { + name: string; + permissions?: string[]; + isSystem?: boolean; + dataScope?: { type: string; targetIds?: string[] }; + status?: 'ACTIVE' | 'DISABLED'; +}): Promise<{ id: string; name: string }> { + const id = ulid(); + const role = await prisma.role.create({ + data: { + id, + code: `it_role_${id.toLowerCase()}`, + name: `${ROLE_NAME_PREFIX}${input.name}`, + description: '集成测试角色', + isSystem: input.isSystem ?? false, + status: input.status ?? 'ACTIVE', + permissions: { + create: (input.permissions ?? []).map((permissionCode) => ({ permissionCode })), + }, + dataScopes: { + create: [ + { + id: ulid(), + scopeType: input.dataScope?.type ?? 'created_by_me', + targetId: input.dataScope?.targetIds?.[0] ?? null, + }, + ], + }, + }, + }); + createdRoleIds.push(role.id); + return { id: role.id, name: role.name }; +} + +async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise { + const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }); + const account = `${ACCOUNT_PREFIX}${suffix}`; + const id = ulid(); + await prisma.user.create({ + data: { + id, + name: `集成测试${suffix}`, + account, + email: `${account}@lingxi.cn`, + phone: `138${String(Date.now()).slice(-8)}`, + passwordHash: PASSWORD_HASH, + departmentId: department.id, + mustChangePassword: false, + roles: { create: [{ roleId }] }, + }, + }); + return { id, account }; +} + describe('identity 集成(真实 PG / Redis)', () => { let created: CreatedApiApp; let manager: FixtureUser; @@ -98,12 +186,17 @@ describe('identity 集成(真实 PG / Redis)', () => { created = await createApp(); await created.app.init(); await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } }); + await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } }); manager = await createFixtureUser('manager', 'platform_admin'); viewer = await createFixtureUser('viewer', 'designer'); }); afterAll(async () => { await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } }); + await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } }); + if (createdRoleIds.length > 0) { + await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } }); + } await prisma.$disconnect(); await created.app.close(); }); @@ -235,4 +328,316 @@ describe('identity 集成(真实 PG / Redis)', () => { expect(roles.statusCode).toBe(200); expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8); }); + + // ------------------------------------------------------------------------- + // A2:角色权限、权限树、SoD、数据范围、停用即时生效 + // ------------------------------------------------------------------------- + + it('GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD;无 role:view 返回 40301', async () => { + const { body } = await login(created.app, manager.account); + const response = await callJson<{ + modules: { module: string; moduleLabel: string }[]; + specialPermissions: { code: string }[]; + sodPairs: { id: string }[]; + totalPermissions: number; + }>(created.app, 'GET', '/permissions', body.accessToken); + expect(response.status).toBe(200); + expect(response.body.totalPermissions).toBe(30); + expect(response.body.modules.map((module) => module.module)).toEqual([ + 'script', + 'material', + 'asset', + 'delivery', + 'customer', + 'customer_group', + 'identity', + 'industry', + 'platform', + 'audit', + ]); + expect(response.body.modules.map((module) => module.moduleLabel)).toContain('客户组'); + expect(response.body.specialPermissions).toHaveLength(6); + expect(response.body.sodPairs).toHaveLength(3); + + // 三态之一:无 role:view(设计师)→ 40301(用独立 fixture,避免受前面停用用例影响) + const designerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'designer' } }); + const designerOnly = await createFixtureUserWithRoleId('permissions-denied', designerRole.id); + const designerLogin = await login(created.app, designerOnly.account); + const denied = await callJson<{ code: number }>( + created.app, + 'GET', + '/permissions', + designerLogin.body.accessToken, + ); + expect(denied.status).toBe(403); + expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED); + }); + + it('GET /roles:默认只返回启用角色(A1 多选兼容),字段含统计与 dataScope', async () => { + const { body } = await login(created.app, manager.account); + const response = await callJson<{ + items: { + id: string; + code: string; + name: string; + isSystem: boolean; + permissionCount: number; + specialPermissionCount: number; + userCount: number; + dataScope: { type: string }; + }[]; + }>(created.app, 'GET', '/roles', body.accessToken); + expect(response.status).toBe(200); + const codes = response.body.items.map((item) => item.code); + expect(codes).not.toContain('review_lead'); + expect(codes).toContain('material_operator'); + const superAdmin = response.body.items.find((item) => item.code === 'super_admin'); + expect(superAdmin?.isSystem).toBe(true); + expect(superAdmin?.permissionCount).toBe(24); + expect(superAdmin?.specialPermissionCount).toBe(6); + expect(superAdmin?.dataScope).toEqual({ type: 'all' }); + + const all = await callJson<{ items: { code: string }[] }>( + created.app, + 'GET', + '/roles?status=all', + body.accessToken, + ); + expect(all.body.items.map((item) => item.code)).toContain('review_lead'); + + // A1 兼容:只有 user:view 的角色也能读取角色列表(用于用户管理的角色多选) + const onlyUserView = await createTestRole({ name: '用户查看', permissions: ['user:view'] }); + const selector = await createFixtureUserWithRoleId('role-selector', onlyUserView.id); + const selectorLogin = await login(created.app, selector.account); + const selectorRoles = await callJson<{ items: { code: string }[] }>( + created.app, + 'GET', + '/roles', + selectorLogin.body.accessToken, + ); + expect(selectorRoles.status).toBe(200); + expect(selectorRoles.body.items.length).toBeGreaterThan(0); + const selectorPermissions = await callJson<{ code: number }>( + created.app, + 'GET', + '/permissions', + selectorLogin.body.accessToken, + ); + expect(selectorPermissions.status).toBe(403); + }); + + it('POST /roles:权限默认全空、code 自动生成、数据范围单选;SoD 命中 40304', async () => { + const { body } = await login(created.app, manager.account); + const createdRole = await callJson<{ + role: { + id: string; + code: string; + status: string; + permissions: string[]; + specialPermissions: string[]; + dataScope: { type: string }; + boundUsers: unknown[]; + }; + affectedUserCount: number; + warnings: string[]; + }>(created.app, 'POST', '/roles', body.accessToken, { + name: `${ROLE_NAME_PREFIX}内容运营`, + permissions: ['script:read'], + dataScope: { type: 'department' }, + }); + expect(createdRole.status).toBe(201); + createdRoleIds.push(createdRole.body.role.id); + expect(createdRole.body.role.code).toMatch(/^custom_[0-9a-z]{26}$/); + expect(createdRole.body.role.permissions).toEqual(['script:read']); + expect(createdRole.body.role.specialPermissions).toEqual([]); + expect(createdRole.body.role.dataScope).toEqual({ type: 'department' }); + expect(createdRole.body.affectedUserCount).toBe(0); + + const sod = await callJson<{ code: number; details: { conflicts: { pairId: string }[] } }>( + created.app, + 'POST', + '/roles', + body.accessToken, + { + name: `${ROLE_NAME_PREFIX}互斥冲突`, + permissions: ['script:write', 'script:review'], + dataScope: { type: 'all' }, + }, + ); + expect(sod.status).toBe(403); + expect(sod.body.code).toBe(ErrorCode.SOD_VIOLATION); + expect(sod.body.details.conflicts[0]?.pairId).toBe('script-write-review'); + + const specialInNormal = await callJson<{ code: number }>( + created.app, + 'POST', + '/roles', + body.accessToken, + { + name: `${ROLE_NAME_PREFIX}特殊权限错位`, + permissions: ['delivery:export'], + dataScope: { type: 'all' }, + }, + ); + expect(specialInNormal.status).toBe(400); + expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID); + }); + + it('PATCH /roles:保存权限返回影响用户数、写 outbox 审计事件;系统角色仅显示名可改', async () => { + const { body } = await login(created.app, manager.account); + const role = await createTestRole({ + name: '待编辑', + permissions: ['script:read'], + dataScope: { type: 'department' }, + }); + const boundUser = await createFixtureUserWithRoleId('role-edit', role.id); + + const patched = await callJson<{ + role: { + permissions: string[]; + specialPermissions: string[]; + dataScope: { type: string }; + status: string; + }; + affectedUserCount: number; + warnings: string[]; + }>(created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, { + permissions: ['script:read', 'user:view'], + specialPermissions: ['delivery:export'], + dataScope: { type: 'owned_customers' }, + }); + expect(patched.status).toBe(200); + expect(patched.body.role.permissions).toEqual(['script:read', 'user:view']); + expect(patched.body.role.specialPermissions).toEqual(['delivery:export']); + expect(patched.body.role.dataScope).toEqual({ type: 'owned_customers' }); + expect(patched.body.affectedUserCount).toBe(1); + + const outbox = await prisma.outboxEvent.findFirst({ + where: { aggregateId: role.id, eventType: 'role.permissions.changed' }, + orderBy: { occurredAt: 'desc' }, + }); + expect(outbox).not.toBeNull(); + const payload = outbox?.payloadJson as { + roleId: string; + affectedUserIds: string[]; + changes: { permissions: { added: string[]; removed: string[] } }; + }; + expect(payload.roleId).toBe(role.id); + expect(payload.affectedUserIds).toEqual([boundUser.id]); + expect(payload.changes.permissions.added).toEqual(['user:view']); + + // 系统角色保护:改权限 40903,仅显示名可改 + const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }); + const protectedUpdate = await callJson<{ code: number }>( + created.app, + 'PATCH', + `/roles/${superAdminRole.id}`, + body.accessToken, + { permissions: ['user:view'] }, + ); + expect(protectedUpdate.status).toBe(409); + expect(protectedUpdate.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + const renamed = await callJson<{ role: { name: string } }>( + created.app, + 'PATCH', + `/roles/${superAdminRole.id}`, + body.accessToken, + { name: '超级管理员' }, + ); + expect(renamed.status).toBe(200); + expect(renamed.body.role.name).toBe('超级管理员'); + }); + + it('DELETE /roles:系统角色 / 有绑定用户拒绝(40903),无绑定用户删除成功', async () => { + const { body } = await login(created.app, manager.account); + const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }); + const systemDelete = await callJson<{ code: number }>( + created.app, + 'DELETE', + `/roles/${superAdminRole.id}`, + body.accessToken, + ); + expect(systemDelete.status).toBe(409); + expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + + const bound = await createTestRole({ name: '有绑定' }); + await createFixtureUserWithRoleId('role-bound', bound.id); + const boundDelete = await callJson<{ code: number; details: { userCount: number } }>( + created.app, + 'DELETE', + `/roles/${bound.id}`, + body.accessToken, + ); + expect(boundDelete.status).toBe(409); + expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + expect(boundDelete.body.details.userCount).toBe(1); + + const free = await createTestRole({ name: '无绑定' }); + const freeDelete = await created.app.inject({ + method: 'DELETE', + url: `${API}/roles/${free.id}`, + headers: { authorization: `Bearer ${body.accessToken}` }, + }); + expect(freeDelete.statusCode).toBe(204); + const gone = await prisma.role.findUnique({ where: { id: free.id } }); + expect(gone).toBeNull(); + }); + + it('角色停用后,绑定用户下一次请求立即失效(40301)', async () => { + const { body } = await login(created.app, manager.account); + const role = await createTestRole({ name: '待停用', permissions: ['user:view'] }); + const user = await createFixtureUserWithRoleId('role-disable', role.id); + const userLogin = await login(created.app, user.account); + expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200); + + const disabled = await callJson<{ role: { status: string }; affectedUserCount: number }>( + created.app, + 'PATCH', + `/roles/${role.id}`, + body.accessToken, + { status: 'disabled', reason: '集成测试停用' }, + ); + expect(disabled.status).toBe(200); + expect(disabled.body.role.status).toBe('disabled'); + expect(disabled.body.affectedUserCount).toBe(1); + expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(403); + + const enabled = await callJson<{ role: { status: string } }>( + created.app, + 'PATCH', + `/roles/${role.id}`, + body.accessToken, + { status: 'enabled' }, + ); + expect(enabled.body.role.status).toBe('active'); + // 再启用后同一 access token 恢复权限(每请求解析,无缓存) + expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200); + }); + + it('/me:dataScopes 多角色并集;超管固定 all;A3 前客户集合为空数组', async () => { + const adminLogin = await login(created.app, manager.account); + expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]); + const adminMe = await callJson(created.app, 'GET', '/me', adminLogin.body.accessToken); + expect(adminMe.body.dataScopes).toEqual([{ type: 'all' }]); + + const ownedRole = await createTestRole({ + name: '负责客户范围', + permissions: ['user:view'], + dataScope: { type: 'owned_customers' }, + }); + const deptRole = await createTestRole({ + name: '部门范围', + permissions: ['customer:read'], + dataScope: { type: 'department' }, + }); + const user = await createFixtureUserWithRoleId('me-scopes', ownedRole.id); + await prisma.userRole.create({ data: { userId: user.id, roleId: deptRole.id } }); + const userLogin = await login(created.app, user.account); + expect(userLogin.status).toBe(200); + const scopes = userLogin.body.me.dataScopes; + expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']); + for (const scope of scopes) { + expect(scope.customerIds).toEqual([]); + } + }); }); diff --git a/apps/api/src/modules/identity/identity.module.ts b/apps/api/src/modules/identity/identity.module.ts index 93571ac..c54b286 100644 --- a/apps/api/src/modules/identity/identity.module.ts +++ b/apps/api/src/modules/identity/identity.module.ts @@ -8,6 +8,7 @@ import { LoginFailureService } from './login-failure.service'; import { MeController } from './me.controller'; import { MeService } from './me.service'; import { PasswordService } from './password.service'; +import { PermissionsController } from './permissions.controller'; import { RolesController } from './roles.controller'; import { RolesService } from './roles.service'; import { UsersController } from './users.controller'; @@ -24,6 +25,7 @@ import { UsersService } from './users.service'; UsersController, DepartmentsController, RolesController, + PermissionsController, ], providers: [ AuthService, diff --git a/apps/api/src/modules/identity/identity.services.spec.ts b/apps/api/src/modules/identity/identity.services.spec.ts index 6a8f1e0..c710955 100644 --- a/apps/api/src/modules/identity/identity.services.spec.ts +++ b/apps/api/src/modules/identity/identity.services.spec.ts @@ -2,17 +2,19 @@ import { ErrorCode } from '@lingxi/shared'; import { describe, expect, it, vi } from 'vitest'; import { AppException } from '../../common/errors/app-exception'; +import type { UserAuthRecord } from '../../common/security/permission.service'; +import type { RequestContextService } from '../../common/security/request-context'; import type { PrismaService } from '../../infra/prisma/prisma.service'; import { DepartmentsService } from './departments.service'; import { LoginFailureService } from './login-failure.service'; import { MeService } from './me.service'; import { PasswordService } from './password.service'; import { RolesService } from './roles.service'; -import { maskPhone, toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper'; +import { maskPhone, toUserDetail, toUserSummary } from './user.mapper'; import { UsersService } from './users.service'; -function userFixture(overrides: Partial = {}): UserWithRelations { - const base: UserWithRelations = { +function userFixture(overrides: Partial = {}): UserAuthRecord { + const base: UserAuthRecord = { id: 'u1', account: 'chen.mo', email: 'chen.mo@lingxi.cn', @@ -51,6 +53,7 @@ function userFixture(overrides: Partial = {}): UserWithRelati createdAt: new Date(), updatedAt: new Date(), }, + children: [], }, roles: [ { @@ -66,6 +69,8 @@ function userFixture(overrides: Partial = {}): UserWithRelati status: 'ACTIVE', createdAt: new Date(), updatedAt: new Date(), + permissions: [], + dataScopes: [], }, }, { @@ -81,6 +86,8 @@ function userFixture(overrides: Partial = {}): UserWithRelati status: 'DISABLED', createdAt: new Date(), updatedAt: new Date(), + permissions: [], + dataScopes: [], }, }, ], @@ -357,43 +364,79 @@ describe('DepartmentsService', () => { }); describe('MeService / RolesService', () => { - it('/me:手机号按 user:manage 返回,角色与数据范围来自 DB', async () => { + const scope = { + userId: 'u1', + isSuperAdmin: false, + dataScopes: [{ type: 'department_and_children' as const, customerIds: [] }], + customerIds: [] as string[], + }; + + it('/me:手机号按 user:manage 返回,dataScopes 来自 ScopeResolver', async () => { const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } }; const service = new MeService(prisma as unknown as PrismaService); - const me = await service.getMe('u1', ['user:manage']); + const me = await service.getMe('u1', ['user:manage'], scope); expect(me.user.phone).toBe('13866202046'); expect(me.permissions).toEqual(['user:manage']); expect(me.roles).toHaveLength(1); - const meWithout = await service.getMe('u1', []); + expect(me.dataScopes).toEqual([{ type: 'department_and_children', customerIds: [] }]); + const meWithout = await service.getMe('u1', [], scope); expect(meWithout.user.phone).toBeUndefined(); await expectRejected( new MeService({ user: { findUnique: vi.fn().mockResolvedValue(null) }, - } as unknown as PrismaService).getMe('u1', []), + } as unknown as PrismaService).getMe('u1', [], scope), ErrorCode.UNAUTHENTICATED, ); + await expectRejected( + new MeService(prisma as unknown as PrismaService).getMe('u1', [], null), + ErrorCode.OUT_OF_SCOPE, + ); }); - it('角色列表只返回启用角色', async () => { + it('角色列表:聚合权限点统计、数据范围与用户数(默认只查启用角色)', async () => { const prisma = { role: { findMany: vi.fn().mockResolvedValue([ { id: 'r1', - code: 'super_admin', - name: '超级管理员', - description: '内置', - isSystem: true, + code: 'reviewer', + name: '审核员', + description: '审核', + isSystem: false, status: 'ACTIVE', - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date('2026-11-01T00:00:00.000Z'), + updatedAt: new Date('2026-11-02T00:00:00.000Z'), + permissions: [ + { permissionCode: 'script:read' }, + { permissionCode: 'script:review' }, + { permissionCode: 'delivery:export' }, + ], + dataScopes: [{ scopeType: 'department_and_children', targetId: null }], + _count: { users: 3 }, }, ]), }, }; - const roles = await new RolesService(prisma as unknown as PrismaService).list(); - expect(roles).toEqual([ - { id: 'r1', code: 'super_admin', name: '超级管理员', isSystem: true, description: '内置' }, - ]); + const service = new RolesService( + prisma as unknown as PrismaService, + { get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService, + ); + const result = await service.list({ + status: 'active', + sort: 'created_at', + order: 'desc', + limit: 20, + }); + expect(result.next_cursor).toBeNull(); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + id: 'r1', + code: 'reviewer', + status: 'active', + permissionCount: 2, + specialPermissionCount: 1, + userCount: 3, + dataScope: { type: 'department_and_children' }, + }); }); }); diff --git a/apps/api/src/modules/identity/me.controller.ts b/apps/api/src/modules/identity/me.controller.ts index 09cdab0..c668467 100644 --- a/apps/api/src/modules/identity/me.controller.ts +++ b/apps/api/src/modules/identity/me.controller.ts @@ -4,10 +4,11 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { AppException } from '../../common/errors/app-exception'; import type { AuthContext } from '../../common/security/auth.types'; -import { CurrentUser } from '../../common/security/decorators'; +import { CurrentScope, CurrentUser } from '../../common/security/decorators'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; import { MeService } from './me.service'; -/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制 */ +/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制与数据范围展示 */ @ApiTags('identity-me') @ApiBearerAuth() @Controller('me') @@ -15,11 +16,14 @@ export class MeController { constructor(@Inject(MeService) private readonly me: MeService) {} @Get() - @ApiOperation({ summary: '当前用户、角色、权限点、数据范围' }) - async current(@CurrentUser() auth: AuthContext | null): Promise { + @ApiOperation({ summary: '当前用户、角色、权限点、数据范围(多角色并集)' }) + async current( + @CurrentUser() auth: AuthContext | null, + @CurrentScope() scope: ScopeContext | null, + ): Promise { if (auth === null) { throw new AppException({ code: ErrorCode.UNAUTHENTICATED }); } - return this.me.getMe(auth.userId, auth.permissions); + return this.me.getMe(auth.userId, auth.permissions, scope); } } diff --git a/apps/api/src/modules/identity/me.service.ts b/apps/api/src/modules/identity/me.service.ts index a70a9a9..5a87829 100644 --- a/apps/api/src/modules/identity/me.service.ts +++ b/apps/api/src/modules/identity/me.service.ts @@ -2,23 +2,35 @@ import { ErrorCode, type MeResponse, Permission } from '@lingxi/shared'; import { Inject, Injectable } from '@nestjs/common'; import { AppException } from '../../common/errors/app-exception'; -import type { UserAuthRecord } from '../../common/security/permission.service'; -import { resolveDataScope } from '../../common/security/permission.service'; -import { activeRolesOf } from '../../common/security/permission.service'; +import { activeRolesOf, type UserAuthRecord } from '../../common/security/permission.service'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; import type { PrismaService } from '../../infra/prisma/prisma.service'; import { PRISMA } from '../../infra/tokens'; import { toRoleSummary, toUserSummary } from './user.mapper'; -/** /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围 */ +/** + * /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围(多角色并集)。 + * `dataScopes` 由 ScopeResolver 解析(每个元素带 customerIds;超管为 [{ type: 'all' }])。 + */ @Injectable() export class MeService { constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} - async getMe(userId: string, permissions: string[]): Promise { + async getMe( + userId: string, + permissions: string[], + scope: ScopeContext | null, + ): Promise { const user = await this.findUser(userId); if (user === null) { throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' }); } + if (scope === null) { + throw new AppException({ + code: ErrorCode.OUT_OF_SCOPE, + message: '数据范围解析失败,已拒绝访问', + }); + } const roles = activeRolesOf(user); const canSeePhone = permissions.includes(Permission.USER_MANAGE); return { @@ -28,7 +40,7 @@ export class MeService { }, roles: roles.map((item) => toRoleSummary(item.role)), permissions, - dataScope: resolveDataScope(roles.map((item) => item.role.code)), + dataScopes: scope.dataScopes, mustChangePassword: user.mustChangePassword, }; } @@ -38,8 +50,8 @@ export class MeService { 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 } } } }, }, }); } diff --git a/apps/api/src/modules/identity/permissions.controller.ts b/apps/api/src/modules/identity/permissions.controller.ts new file mode 100644 index 0000000..013b399 --- /dev/null +++ b/apps/api/src/modules/identity/permissions.controller.ts @@ -0,0 +1,29 @@ +import { Permission, type PermissionTree } from '@lingxi/shared'; +import { Controller, Get, Inject } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { PermissionCatalogService } from '../../common/security/catalog/permission-catalog.service'; +import { RequirePermissions } from '../../common/security/decorators'; + +/** + * 权限点目录(A2-BE-02):前端角色编辑器据此渲染「模块 × 操作」勾选树、 + * 特殊权限区域与 SoD 互斥对高亮。目录由 `@lingxi/shared` 代码注册,不读业务 DB。 + */ +@ApiTags('identity-permissions') +@ApiBearerAuth() +@Controller('permissions') +export class PermissionsController { + constructor( + @Inject(PermissionCatalogService) private readonly catalog: PermissionCatalogService, + ) {} + + @Get() + @RequirePermissions(Permission.ROLE_VIEW) + @ApiOperation({ + summary: '权限点目录(10 模块树 + 特殊权限 + SoD 对)', + description: '模块顺序与 design/a2-ui-v1/brief.md 对齐;特殊权限默认关闭、单独授予', + }) + getTree(): PermissionTree { + return this.catalog.buildTree(); + } +} diff --git a/apps/api/src/modules/identity/roles.controller.ts b/apps/api/src/modules/identity/roles.controller.ts index 62b1de4..0db348a 100644 --- a/apps/api/src/modules/identity/roles.controller.ts +++ b/apps/api/src/modules/identity/roles.controller.ts @@ -1,11 +1,49 @@ -import { type ListResponse, Permission, type RoleSummary } from '@lingxi/shared'; -import { Controller, Get, Inject } from '@nestjs/common'; +import { + type CreateRoleInput, + createRoleInputSchema, + ErrorCode, + Permission, + type RoleDetail, + type RoleListQuery, + roleListQuerySchema, + type RoleListResponse, + type RoleSaveResult, + type UpdateRoleInput, + updateRoleInputSchema, +} from '@lingxi/shared'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Inject, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RequirePermissions } from '../../common/security/decorators'; +import { AppException } from '../../common/errors/app-exception'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import type { AuthContext } from '../../common/security/auth.types'; +import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators'; import { RolesService } from './roles.service'; -/** 角色列表:供用户管理页做角色多选绑定 */ +function requireAuth(auth: AuthContext | null): AuthContext { + if (auth === null) { + throw new AppException({ code: ErrorCode.UNAUTHENTICATED }); + } + return auth; +} + +/** + * 角色管理(A2-BE-01 / 03 / 05)。 + * + * 兼容 A1:`GET /roles` 同时接受 `role:view` 与 `user:view`(用户管理页的角色多选), + * 默认只返回启用角色,返回项包含 `RoleSummary` 的 id / code / name / isSystem / description。 + */ @ApiTags('identity-roles') @ApiBearerAuth() @Controller('roles') @@ -13,9 +51,59 @@ export class RolesController { constructor(@Inject(RolesService) private readonly roles: RolesService) {} @Get() - @RequirePermissions(Permission.USER_VIEW) - @ApiOperation({ summary: '角色列表(绑定用)' }) - async list(): Promise> { - return { items: await this.roles.list(), next_cursor: null }; + @RequirePermissions(Permission.ROLE_VIEW, Permission.USER_VIEW) + @ApiOperation({ + summary: '角色列表(游标分页 + 筛选 + 排序)', + description: '默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围', + }) + async list( + @Query(new ZodValidationPipe(roleListQuerySchema)) query: RoleListQuery, + ): Promise { + return this.roles.list(query); + } + + @Get(':id') + @RequirePermissions(Permission.ROLE_VIEW) + @ApiOperation({ summary: '角色详情(权限点、特殊权限、数据范围、绑定用户)' }) + async detail(@Param('id') id: string): Promise { + return this.roles.detail(id); + } + + @Post() + @RequirePermissions(Permission.ROLE_MANAGE) + @Audit('identity.role.create') + @ApiOperation({ + summary: '新建角色', + description: '权限点默认全空、code 自动生成;保存即做 SoD 校验(40304);特殊权限单独授予', + }) + async create( + @Body(new ZodValidationPipe(createRoleInputSchema)) input: CreateRoleInput, + @CurrentUser() auth: AuthContext | null, + ): Promise { + return this.roles.create(input, requireAuth(auth).userId); + } + + @Patch(':id') + @RequirePermissions(Permission.ROLE_MANAGE) + @Audit('identity.role.update') + @ApiOperation({ + summary: '更新角色 / 启停', + description: '系统角色仅显示名可改;status=enabled/disabled;返回 affectedUserCount 与软提示', + }) + async update( + @Param('id') id: string, + @Body(new ZodValidationPipe(updateRoleInputSchema)) input: UpdateRoleInput, + @CurrentUser() auth: AuthContext | null, + ): Promise { + return this.roles.update(id, input, requireAuth(auth).userId); + } + + @Delete(':id') + @HttpCode(204) + @RequirePermissions(Permission.ROLE_MANAGE) + @Audit('identity.role.delete') + @ApiOperation({ summary: '删除角色', description: '系统角色拒绝;有绑定用户(40903)只能停用' }) + async remove(@Param('id') id: string, @CurrentUser() auth: AuthContext | null): Promise { + await this.roles.remove(id, requireAuth(auth).userId); } } diff --git a/apps/api/src/modules/identity/roles.int.spec.ts b/apps/api/src/modules/identity/roles.int.spec.ts new file mode 100644 index 0000000..31c735d --- /dev/null +++ b/apps/api/src/modules/identity/roles.int.spec.ts @@ -0,0 +1,1034 @@ +import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createApp, type CreatedApiApp } from '../../bootstrap'; +import type { CustomerDirectory } from '../../common/security/scope/customer-directory'; +import { ScopeResolver } from '../../common/security/scope/scope-resolver.service'; +import { loadDotenv } from '../../config/load-dotenv'; + +/** + * A2-QA-01 · 角色权限端点集成用例(真实 PG / Redis,进程内 Fastify inject) + * + * 覆盖开发计划 §6.3 A2-QA-01 / 工程规范 06-TST-05 与测试计划 §10.3 权限三态矩阵: + * 1. 权限三态:GET /roles、GET /roles/{id}、POST /roles、PATCH /roles/{id}、 + * DELETE /roles/{id}、GET /permissions 的「有权限 / 无功能权限 / 数据范围解析失败」; + * 外加 40401(资源不存在)/ 40903(系统角色、有绑定用户删除)/ 40302(范围解析失败 fail-closed)。 + * 2. SoD 三对:POST / PATCH 命中 40304,details.conflicts 含 pairId;系统角色隐式全集豁免。 + * 3. 即时生效:改权限 / 授特殊权限 / 停用 / 恢复后,同一 access token 的下一次请求即变化。 + * 4. 特殊权限:默认不进普通 permissions、permissions 与 specialPermissions 分桶校验、 + * 单独授予后 /me 含该编码;复制语义(仅提交普通权限)不带入来源角色特殊权限。 + * 5. A1 兼容:GET /roles 默认 status=active 且接受 user:view。 + * + * 与 identity.int.spec.ts 的分工:那边已覆盖登录链路、列表默认口径、POST/PATCH/DELETE 主流程、 + * outbox 主路径与停用后 403;本文件按三态矩阵逐端点补齐缺失边界,使用独立数据前缀 + * (用户 `itqa2.` / 角色名 `ITQA2-角色-`)与 self-clean,两个文件可并行执行。 + */ + +/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */ +const PASSWORD_HASH = + '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; +const PASSWORD = 'lingxi2026'; +const ACCOUNT_PREFIX = 'itqa2.'; +const ROLE_NAME_PREFIX = 'ITQA2-角色-'; +const API = API_PREFIX; + +const prisma = new PrismaClient(); + +interface FixtureUser { + id: string; + account: string; +} + +interface ErrorBody { + code: number; + message: string; + details?: unknown; +} + +interface RoleDetailBody { + role: { + id: string; + code: string; + name: string; + isSystem: boolean; + status: 'active' | 'disabled'; + permissionCount: number; + specialPermissionCount: number; + userCount: number; + permissions: string[]; + specialPermissions: string[]; + dataScope: { type: string; targetIds?: string[] }; + boundUsers: { id: string; account: string }[]; + }; + affectedUserCount: number; + warnings: string[]; +} + +interface RoleDetailResponse { + id: string; + code: string; + name: string; + isSystem: boolean; + status: 'active' | 'disabled'; + permissionCount: number; + specialPermissionCount: number; + userCount: number; + permissions: string[]; + specialPermissions: string[]; + dataScope: { type: string; targetIds?: string[] }; + boundUsers: { id: string; account: string }[]; +} + +interface MeBody { + permissions: string[]; + roles: { code: string }[]; + dataScopes: { type: string; customerIds?: string[] }[]; +} + +interface TokenBody { + accessToken: string; + refreshToken: string; + me: MeBody; +} + +interface JsonResult { + status: number; + body: T; +} + +let phoneCounter = 0; +/** 139 段 + 8 位序号,避免与 identity.int.spec.ts 的 137/138 段以及 seed 数据冲突 */ +function nextPhone(): string { + phoneCounter += 1; + return `139${String(phoneCounter).padStart(8, '0')}`; +} + +async function createFixtureUser(suffix: string, roleCode: string): Promise { + const [department, role] = await Promise.all([ + prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }), + prisma.role.findUniqueOrThrow({ where: { code: roleCode } }), + ]); + const account = `${ACCOUNT_PREFIX}${suffix}`; + const id = ulid(); + await prisma.user.create({ + data: { + id, + name: `角色集成测试${suffix}`, + account, + email: `${account}@lingxi.cn`, + phone: nextPhone(), + passwordHash: PASSWORD_HASH, + departmentId: department.id, + mustChangePassword: false, + roles: { create: [{ roleId: role.id }] }, + }, + }); + return { id, account }; +} + +async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise { + const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }); + const account = `${ACCOUNT_PREFIX}${suffix}`; + const id = ulid(); + await prisma.user.create({ + data: { + id, + name: `角色集成测试${suffix}`, + account, + email: `${account}@lingxi.cn`, + phone: nextPhone(), + passwordHash: PASSWORD_HASH, + departmentId: department.id, + mustChangePassword: false, + roles: { create: [{ roleId }] }, + }, + }); + return { id, account }; +} + +/** 集成测试创建的角色:afterAll 清理 outbox 占位事件,避免污染本地库 */ +const createdRoleIds: string[] = []; + +async function createTestRole(input: { + name: string; + permissions?: string[]; + specialPermissions?: string[]; + isSystem?: boolean; + status?: 'ACTIVE' | 'DISABLED'; + dataScope?: { type: string; targetIds?: string[] }; +}): Promise<{ id: string; name: string }> { + const id = ulid(); + const role = await prisma.role.create({ + data: { + id, + code: `itqa2_role_${id.toLowerCase()}`, + name: `${ROLE_NAME_PREFIX}${input.name}`, + description: 'A2-QA-01 集成测试角色', + isSystem: input.isSystem ?? false, + status: input.status ?? 'ACTIVE', + permissions: { + create: [...(input.permissions ?? []), ...(input.specialPermissions ?? [])].map( + (permissionCode) => ({ permissionCode }), + ), + }, + dataScopes: { + create: [ + { + id: ulid(), + scopeType: input.dataScope?.type ?? 'created_by_me', + targetId: input.dataScope?.targetIds?.[0] ?? null, + }, + ], + }, + }, + }); + createdRoleIds.push(role.id); + return { id: role.id, name: role.name }; +} + +async function login( + app: CreatedApiApp['app'], + account: string, +): Promise<{ status: number; body: TokenBody }> { + const response = await app.inject({ + method: 'POST', + url: `${API}/auth/login`, + payload: { account, password: PASSWORD }, + }); + return { status: response.statusCode, body: response.json() }; +} + +async function callJson( + app: CreatedApiApp['app'], + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + url: string, + token: string, + payload?: unknown, +): Promise> { + const response = await app.inject({ + method, + url: `${API}${url}`, + headers: { authorization: `Bearer ${token}` }, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + const text = response.body; + const body = (text.length === 0 ? null : response.json()) as T; + return { status: response.statusCode, body }; +} + +async function getMe(app: CreatedApiApp['app'], token: string): Promise> { + return callJson(app, 'GET', '/me', token); +} + +function permissionsSet(body: { permissions: string[] }): Set { + return new Set(body.permissions); +} + +describe('角色权限集成(A2-QA-01:权限三态 / SoD / 即时生效)', () => { + let created: CreatedApiApp; + let manager: FixtureUser; + /** 仅 role:view:可读角色与权限树,不可写 */ + let roleViewOnly: FixtureUser; + /** 仅 user:view:A1 用户管理页兼容,可读角色列表但不可读详情 / 权限树 */ + let userViewOnly: FixtureUser; + /** 无角色相关功能权限 */ + let noPerm: FixtureUser; + let roleViewRole: { id: string; name: string }; + let userViewRole: { id: string; name: string }; + + beforeAll(async () => { + loadDotenv(); + created = await createApp(); + await created.app.init(); + + // 自清理:先清掉上次异常中断遗留的数据(前缀隔离,不触碰其他用例 / seed) + await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } }); + const leftovers = await prisma.role.findMany({ + where: { name: { startsWith: ROLE_NAME_PREFIX } }, + select: { id: true }, + }); + if (leftovers.length > 0) { + await prisma.role.deleteMany({ where: { id: { in: leftovers.map((row) => row.id) } } }); + await prisma.outboxEvent.deleteMany({ + where: { aggregateId: { in: leftovers.map((row) => row.id) } }, + }); + } + + manager = await createFixtureUser('manager', 'platform_admin'); + roleViewRole = await createTestRole({ + name: '只读', + permissions: ['role:view'], + dataScope: { type: 'all' }, + }); + userViewRole = await createTestRole({ + name: '用户查看', + permissions: ['user:view'], + dataScope: { type: 'all' }, + }); + roleViewOnly = await createFixtureUserWithRoleId('role-view', roleViewRole.id); + userViewOnly = await createFixtureUserWithRoleId('user-view', userViewRole.id); + noPerm = await createFixtureUserWithRoleId( + 'no-perm', + ( + await createTestRole({ + name: '无角色权限', + permissions: ['script:read'], + dataScope: { type: 'created_by_me' }, + }) + ).id, + ); + }); + + afterAll(async () => { + await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } }); + await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } }); + if (createdRoleIds.length > 0) { + await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } }); + } + await prisma.$disconnect(); + await created.app.close(); + }); + + // ------------------------------------------------------------------------- + // 1. 权限三态(矩阵:有权限 / 无功能权限 / 数据范围解析失败) + // ------------------------------------------------------------------------- + + it('GET /roles 三态:role:view 与 user:view 均可读(A1 兼容),无权限 40301 且默认只回启用角色', async () => { + const adminLogin = await login(created.app, manager.account); + expect(adminLogin.status).toBe(200); + + const adminList = await callJson<{ items: { code: string; status: string }[] }>( + created.app, + 'GET', + '/roles', + adminLogin.body.accessToken, + ); + expect(adminList.status).toBe(200); + expect(adminList.body.items.length).toBeGreaterThan(0); + // 默认 status=active(A1 用户管理页角色多选的兼容口径) + expect(adminList.body.items.every((item) => item.status === 'active')).toBe(true); + expect(adminList.body.items.map((item) => item.code)).not.toContain('review_lead'); + + const roleViewLogin = await login(created.app, roleViewOnly.account); + const roleViewList = await callJson<{ items: unknown[] }>( + created.app, + 'GET', + '/roles', + roleViewLogin.body.accessToken, + ); + expect(roleViewList.status).toBe(200); + + // A1 兼容:只有 user:view 的账号(用户管理页角色多选)也能读角色列表 + const userViewLogin = await login(created.app, userViewOnly.account); + const userViewList = await callJson<{ items: unknown[] }>( + created.app, + 'GET', + '/roles', + userViewLogin.body.accessToken, + ); + expect(userViewList.status).toBe(200); + expect(userViewList.body.items.length).toBeGreaterThan(0); + + // 无功能权限 → 403 40301 + const noPermLogin = await login(created.app, noPerm.account); + const denied = await callJson( + created.app, + 'GET', + '/roles', + noPermLogin.body.accessToken, + ); + expect(denied.status).toBe(403); + expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED); + }); + + it('GET /roles/{id} 与 GET /permissions:role:view 200;user:view / 无权限 40301', async () => { + const adminLogin = await login(created.app, manager.account); + const roleViewLogin = await login(created.app, roleViewOnly.account); + const userViewLogin = await login(created.app, userViewOnly.account); + const noPermLogin = await login(created.app, noPerm.account); + + const detail = await callJson( + created.app, + 'GET', + `/roles/${roleViewRole.id}`, + roleViewLogin.body.accessToken, + ); + expect(detail.status).toBe(200); + expect(detail.body.permissions).toEqual(['role:view']); + + const tree = await callJson<{ + modules: unknown[]; + specialPermissions: unknown[]; + sodPairs: unknown[]; + totalPermissions: number; + }>(created.app, 'GET', '/permissions', roleViewLogin.body.accessToken); + expect(tree.status).toBe(200); + expect(tree.body.totalPermissions).toBe(30); + expect(tree.body.modules).toHaveLength(10); + expect(tree.body.specialPermissions).toHaveLength(6); + expect(tree.body.sodPairs).toHaveLength(3); + + // user:view 只兼容列表,不覆盖角色详情 / 权限树(仍为功能权限缺失 40301) + for (const token of [userViewLogin.body.accessToken, noPermLogin.body.accessToken]) { + const deniedDetail = await callJson( + created.app, + 'GET', + `/roles/${roleViewRole.id}`, + token, + ); + expect(deniedDetail.status).toBe(403); + expect(deniedDetail.body.code).toBe(ErrorCode.PERMISSION_DENIED); + + const deniedTree = await callJson(created.app, 'GET', '/permissions', token); + expect(deniedTree.status).toBe(403); + expect(deniedTree.body.code).toBe(ErrorCode.PERMISSION_DENIED); + } + + // 管理员可读权限目录(目录为代码注册,与 DB 无关) + const adminTree = await callJson<{ totalPermissions: number }>( + created.app, + 'GET', + '/permissions', + adminLogin.body.accessToken, + ); + expect(adminTree.status).toBe(200); + expect(adminTree.body.totalPermissions).toBe(30); + }); + + it('POST / PATCH / DELETE /roles:无 role:manage 均 40301(只读账号不能写)', async () => { + const roleViewLogin = await login(created.app, roleViewOnly.account); + const noPermLogin = await login(created.app, noPerm.account); + const createPayload = { name: `${ROLE_NAME_PREFIX}越权创建`, dataScope: { type: 'all' } }; + + for (const token of [roleViewLogin.body.accessToken, noPermLogin.body.accessToken]) { + const create = await callJson(created.app, 'POST', '/roles', token, createPayload); + expect(create.status).toBe(403); + expect(create.body.code).toBe(ErrorCode.PERMISSION_DENIED); + + const patch = await callJson( + created.app, + 'PATCH', + `/roles/${roleViewRole.id}`, + token, + { + name: `${ROLE_NAME_PREFIX}越权改名`, + }, + ); + expect(patch.status).toBe(403); + expect(patch.body.code).toBe(ErrorCode.PERMISSION_DENIED); + + const remove = await callJson( + created.app, + 'DELETE', + `/roles/${roleViewRole.id}`, + token, + ); + expect(remove.status).toBe(403); + expect(remove.body.code).toBe(ErrorCode.PERMISSION_DENIED); + } + + // 越权创建未落库 + const ghost = await prisma.role.findFirst({ + where: { name: `${ROLE_NAME_PREFIX}越权创建` }, + }); + expect(ghost).toBeNull(); + }); + + it('资源不存在 → 40401;系统角色 / 有绑定用户删除 → 40903', async () => { + const adminLogin = await login(created.app, manager.account); + const token = adminLogin.body.accessToken; + const missingId = ulid(); + + const detail = await callJson(created.app, 'GET', `/roles/${missingId}`, token); + expect(detail.status).toBe(404); + expect(detail.body.code).toBe(ErrorCode.NOT_FOUND); + + const patch = await callJson(created.app, 'PATCH', `/roles/${missingId}`, token, { + name: `${ROLE_NAME_PREFIX}不存在`, + }); + expect(patch.status).toBe(404); + expect(patch.body.code).toBe(ErrorCode.NOT_FOUND); + + const remove = await callJson(created.app, 'DELETE', `/roles/${missingId}`, token); + expect(remove.status).toBe(404); + expect(remove.body.code).toBe(ErrorCode.NOT_FOUND); + + // 系统角色不可删除 + const superAdmin = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }); + const systemDelete = await callJson( + created.app, + 'DELETE', + `/roles/${superAdmin.id}`, + token, + ); + expect(systemDelete.status).toBe(409); + expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + + // 有绑定用户只能停用 + const bound = await createTestRole({ name: '有绑定删除保护', dataScope: { type: 'all' } }); + await createFixtureUserWithRoleId('bound-delete', bound.id); + const boundDelete = await callJson( + created.app, + 'DELETE', + `/roles/${bound.id}`, + token, + ); + expect(boundDelete.status).toBe(409); + expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + expect(boundDelete.body.details.userCount).toBe(1); + }); + + it('角色停用状态矩阵:默认列表隐藏、status=all/disabled 可查、详情可读、绑定用户权限立即失效', async () => { + const adminLogin = await login(created.app, manager.account); + const token = adminLogin.body.accessToken; + const disabled = await createTestRole({ + name: '停用态', + permissions: ['user:view'], + status: 'DISABLED', + dataScope: { type: 'all' }, + }); + const boundUser = await createFixtureUserWithRoleId('disabled-role', disabled.id); + + const defaultList = await callJson<{ items: { id: string }[] }>( + created.app, + 'GET', + '/roles', + token, + ); + expect(defaultList.status).toBe(200); + expect(defaultList.body.items.map((item) => item.id)).not.toContain(disabled.id); + + const allList = await callJson<{ items: { id: string; status: string }[] }>( + created.app, + 'GET', + '/roles?status=all', + token, + ); + expect(allList.body.items.map((item) => item.id)).toContain(disabled.id); + + const disabledList = await callJson<{ items: { id: string; status: string }[] }>( + created.app, + 'GET', + '/roles?status=disabled', + token, + ); + expect(disabledList.body.items.length).toBeGreaterThan(0); + expect(disabledList.body.items.every((item) => item.status === 'disabled')).toBe(true); + expect(disabledList.body.items.map((item) => item.id)).toContain(disabled.id); + + const detail = await callJson( + created.app, + 'GET', + `/roles/${disabled.id}`, + token, + ); + expect(detail.status).toBe(200); + expect(detail.body.status).toBe('disabled'); + + // 停用角色不参与权限解析:绑定用户 /me 无权限,受保护端点 40301 + const userLogin = await login(created.app, boundUser.account); + expect(userLogin.status).toBe(200); + const me = await getMe(created.app, userLogin.body.accessToken); + expect(me.status).toBe(200); + expect(me.body.permissions).toEqual([]); + const denied = await callJson( + created.app, + 'GET', + '/roles', + userLogin.body.accessToken, + ); + expect(denied.status).toBe(403); + expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED); + }); + + it('数据范围解析失败 fail-closed:角色端点返回 40302(不降级为全部客户)', async () => { + const scopedRole = await createTestRole({ + name: '范围解析失败', + permissions: ['role:view'], + dataScope: { type: 'owned_customers' }, + }); + const scopedUser = await createFixtureUserWithRoleId('scope-fail', scopedRole.id); + const scopedLogin = await login(created.app, scopedUser.account); + const adminLogin = await login(created.app, manager.account); + + expect( + ( + await callJson<{ items: unknown[] }>( + created.app, + 'GET', + '/roles', + scopedLogin.body.accessToken, + ) + ).status, + ).toBe(200); + + const resolver = created.app.get(ScopeResolver); + const mutableResolver = resolver as unknown as { customers: CustomerDirectory }; + const original = mutableResolver.customers; + const brokenDirectory: CustomerDirectory = { + findOwnedCustomerIds: () => Promise.reject(new Error('客户目录端口不可用(模拟 A3 故障)')), + findCustomerGroupCustomerIds: () => Promise.reject(new Error('客户组目录不可用')), + findDepartmentCustomerIds: () => Promise.reject(new Error('客户目录不可用')), + findCreatedCustomerIds: () => Promise.reject(new Error('客户目录不可用')), + }; + try { + mutableResolver.customers = brokenDirectory; + // 非超管且非「全部客户」范围:ScopeGuard 解析失败 → 40302,不降级放行 + const denied = await callJson( + created.app, + 'GET', + '/roles', + scopedLogin.body.accessToken, + ); + expect(denied.status).toBe(403); + expect(denied.body.code).toBe(ErrorCode.OUT_OF_SCOPE); + + const deniedTree = await callJson( + created.app, + 'GET', + '/permissions', + scopedLogin.body.accessToken, + ); + expect(deniedTree.status).toBe(403); + expect(deniedTree.body.code).toBe(ErrorCode.OUT_OF_SCOPE); + + // 「全部客户」与超管不触发客户目录端口,仍可访问(fail-closed 只影响需解析的范围) + const adminList = await callJson<{ items: unknown[] }>( + created.app, + 'GET', + '/roles', + adminLogin.body.accessToken, + ); + expect(adminList.status).toBe(200); + } finally { + mutableResolver.customers = original; + } + + const recovered = await callJson<{ items: unknown[] }>( + created.app, + 'GET', + '/roles', + scopedLogin.body.accessToken, + ); + expect(recovered.status).toBe(200); + }); + + // ------------------------------------------------------------------------- + // 2. SoD 三对(40304) + // ------------------------------------------------------------------------- + + it('SoD 三对:POST 行业包对 / 客户字段对、PATCH 脚本对均命中 40304,conflicts 含 pair 与两侧权限', async () => { + const adminLogin = await login(created.app, manager.account); + const token = adminLogin.body.accessToken; + + interface SodErrorBody extends ErrorBody { + details: { + conflicts: { pairId: string; label: string; permissions: [string, string] }[]; + }; + } + + const industryConflict = await callJson(created.app, 'POST', '/roles', token, { + name: `${ROLE_NAME_PREFIX}行业包互斥`, + permissions: ['industry:manage'], + specialPermissions: ['industry:publish'], + dataScope: { type: 'all' }, + }); + expect(industryConflict.status).toBe(403); + expect(industryConflict.body.code).toBe(ErrorCode.SOD_VIOLATION); + expect(industryConflict.body.details.conflicts.map((conflict) => conflict.pairId)).toEqual([ + 'industry-manage-publish', + ]); + expect(industryConflict.body.details.conflicts[0]?.permissions).toEqual([ + 'industry:manage', + 'industry:publish', + ]); + + const fieldConflict = await callJson(created.app, 'POST', '/roles', token, { + name: `${ROLE_NAME_PREFIX}字段审批互斥`, + permissions: ['customer_field:apply'], + specialPermissions: ['customer_field:approve'], + dataScope: { type: 'all' }, + }); + expect(fieldConflict.status).toBe(403); + expect(fieldConflict.body.code).toBe(ErrorCode.SOD_VIOLATION); + expect(fieldConflict.body.details.conflicts.map((conflict) => conflict.pairId)).toEqual([ + 'customer-field-apply-approve', + ]); + expect(fieldConflict.body.details.conflicts[0]?.permissions).toEqual([ + 'customer_field:apply', + 'customer_field:approve', + ]); + + // 失败请求不落库 + for (const name of [`${ROLE_NAME_PREFIX}行业包互斥`, `${ROLE_NAME_PREFIX}字段审批互斥`]) { + expect(await prisma.role.findFirst({ where: { name } })).toBeNull(); + } + + // PATCH 路径:在已有普通权限上追加互斥的另一侧 → 40304,且角色未被改动 + const patchTarget = await createTestRole({ + name: '待改脚本权限', + permissions: ['script:read', 'user:view'], + dataScope: { type: 'all' }, + }); + const patchConflict = await callJson( + created.app, + 'PATCH', + `/roles/${patchTarget.id}`, + token, + { permissions: ['script:read', 'script:write', 'script:review'] }, + ); + expect(patchConflict.status).toBe(403); + expect(patchConflict.body.code).toBe(ErrorCode.SOD_VIOLATION); + expect(patchConflict.body.details.conflicts.map((conflict) => conflict.pairId)).toEqual([ + 'script-write-review', + ]); + + const unchanged = await callJson( + created.app, + 'GET', + `/roles/${patchTarget.id}`, + token, + ); + expect(unchanged.body.permissions).toEqual(['script:read', 'user:view']); + + // PATCH 只改特殊权限也会触发 SoD(合并后的快照参与校验) + const specialTarget = await createTestRole({ + name: '待授行业发布', + permissions: ['industry:manage'], + dataScope: { type: 'all' }, + }); + const specialConflict = await callJson( + created.app, + 'PATCH', + `/roles/${specialTarget.id}`, + token, + { specialPermissions: ['industry:publish'] }, + ); + expect(specialConflict.status).toBe(403); + expect(specialConflict.body.code).toBe(ErrorCode.SOD_VIOLATION); + expect(specialConflict.body.details.conflicts[0]?.pairId).toBe('industry-manage-publish'); + }); + + it('SoD 豁免边界:系统角色隐式全集不受互斥限制(仅显示名可改),超管创建普通角色仍被 40304 拦截', async () => { + const adminLogin = await login(created.app, manager.account); + const token = adminLogin.body.accessToken; + const superAdmin = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }); + + const superDetail = await callJson( + created.app, + 'GET', + `/roles/${superAdmin.id}`, + token, + ); + expect(superDetail.status).toBe(200); + expect(superDetail.body.isSystem).toBe(true); + expect(superDetail.body.permissionCount).toBe(24); + expect(superDetail.body.specialPermissionCount).toBe(6); + // 隐式全集同时包含三对 SoD 的两侧,系统角色不做互斥校验(豁免) + for (const [left, right] of [ + ['script:write', 'script:review'], + ['industry:manage', 'industry:publish'], + ['customer_field:apply', 'customer_field:approve'], + ] as const) { + expect(superDetail.body.permissions).toContain(left); + expect(superDetail.body.specialPermissions.concat(superDetail.body.permissions)).toContain( + right, + ); + } + + const protectedPatch = await callJson( + created.app, + 'PATCH', + `/roles/${superAdmin.id}`, + token, + { permissions: ['user:view'] }, + ); + expect(protectedPatch.status).toBe(409); + expect(protectedPatch.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + + const protectedStatus = await callJson( + created.app, + 'PATCH', + `/roles/${superAdmin.id}`, + token, + { status: 'disabled' }, + ); + expect(protectedStatus.status).toBe(409); + expect(protectedStatus.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + + const protectedScope = await callJson( + created.app, + 'PATCH', + `/roles/${superAdmin.id}`, + token, + { dataScope: { type: 'created_by_me' } }, + ); + expect(protectedScope.status).toBe(409); + expect(protectedScope.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + + // 仅显示名可改:用同名幂等改名验证放行(避免扰动并行用例) + const renamed = await callJson( + created.app, + 'PATCH', + `/roles/${superAdmin.id}`, + token, + { name: superAdmin.name }, + ); + expect(renamed.status).toBe(200); + expect(renamed.body.role.name).toBe(superAdmin.name); + + // 超管「身份」不豁免 SoD:创建普通角色时仍按普通角色校验 + const createConflict = await callJson(created.app, 'POST', '/roles', token, { + name: `${ROLE_NAME_PREFIX}超管创建互斥`, + permissions: ['script:write', 'script:review'], + dataScope: { type: 'all' }, + }); + expect(createConflict.status).toBe(403); + expect(createConflict.body.code).toBe(ErrorCode.SOD_VIOLATION); + }); + + // ------------------------------------------------------------------------- + // 3. 特殊权限分桶 / 单独授予 / 复制语义 + // ------------------------------------------------------------------------- + + it('特殊权限分桶:混入普通 permissions、普通权限混入 specialPermissions、未登记编码均 40001', async () => { + const adminLogin = await login(created.app, manager.account); + const token = adminLogin.body.accessToken; + + const specialInNormal = await callJson(created.app, 'POST', '/roles', token, { + name: `${ROLE_NAME_PREFIX}特殊错位`, + permissions: ['industry:publish'], + dataScope: { type: 'all' }, + }); + expect(specialInNormal.status).toBe(400); + expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID); + + const normalInSpecial = await callJson(created.app, 'POST', '/roles', token, { + name: `${ROLE_NAME_PREFIX}普通错位`, + permissions: [], + specialPermissions: ['script:read'], + dataScope: { type: 'all' }, + }); + expect(normalInSpecial.status).toBe(400); + expect(normalInSpecial.body.code).toBe(ErrorCode.PARAM_INVALID); + + const unknownCode = await callJson(created.app, 'POST', '/roles', token, { + name: `${ROLE_NAME_PREFIX}幽灵权限`, + permissions: ['ghost:read'], + dataScope: { type: 'all' }, + }); + expect(unknownCode.status).toBe(400); + expect(unknownCode.body.code).toBe(ErrorCode.PARAM_INVALID); + + // 失败请求全部未落库 + expect( + await prisma.role.count({ where: { name: { startsWith: `${ROLE_NAME_PREFIX}特殊错位` } } }), + ).toBe(0); + }); + + it('特殊权限单独授予:普通权限默认为空、计数分桶正确,绑定用户 /me 含特殊权限编码', async () => { + const adminLogin = await login(created.app, manager.account); + const token = adminLogin.body.accessToken; + + const createdRole = await callJson(created.app, 'POST', '/roles', token, { + name: `${ROLE_NAME_PREFIX}审计只读`, + description: '只有审计查看特殊权限', + specialPermissions: ['audit:read'], + dataScope: { type: 'all' }, + }); + expect(createdRole.status).toBe(201); + createdRoleIds.push(createdRole.body.role.id); + expect(createdRole.body.role.permissions).toEqual([]); + expect(createdRole.body.role.specialPermissions).toEqual(['audit:read']); + expect(createdRole.body.role.permissionCount).toBe(0); + expect(createdRole.body.role.specialPermissionCount).toBe(1); + + const boundUser = await createFixtureUserWithRoleId('special-audit', createdRole.body.role.id); + const userLogin = await login(created.app, boundUser.account); + const me = await getMe(created.app, userLogin.body.accessToken); + expect(me.status).toBe(200); + expect(me.body.permissions).toEqual(['audit:read']); + // 特殊权限不出现在普通权限数量里(列表计数口径) + const list = await callJson<{ + items: { id: string; permissionCount: number; specialPermissionCount: number }[]; + }>(created.app, 'GET', '/roles?status=all', token); + const item = list.body.items.find((row) => row.id === createdRole.body.role.id); + expect(item?.permissionCount).toBe(0); + expect(item?.specialPermissionCount).toBe(1); + }); + + it('复制语义(API 层):仅提交来源普通权限创建副本时不带入特殊权限,来源角色不受影响', async () => { + const adminLogin = await login(created.app, manager.account); + const token = adminLogin.body.accessToken; + const source = await createTestRole({ + name: '复制来源', + permissions: ['script:read', 'user:view'], + specialPermissions: ['delivery:export', 'audit:export'], + dataScope: { type: 'department' }, + }); + + const sourceDetail = await callJson( + created.app, + 'GET', + `/roles/${source.id}`, + token, + ); + expect(sourceDetail.body.specialPermissions).toEqual(['audit:export', 'delivery:export']); + + // 前端「复制」实际提交:来源 permissions(普通)+ dataScope,specialPermissions 不提交 + const copy = await callJson(created.app, 'POST', '/roles', token, { + name: `${ROLE_NAME_PREFIX}复制副本`, + description: '复制来源', + permissions: sourceDetail.body.permissions, + dataScope: sourceDetail.body.dataScope, + }); + expect(copy.status).toBe(201); + createdRoleIds.push(copy.body.role.id); + expect(copy.body.role.permissions).toEqual(['script:read', 'user:view']); + expect(copy.body.role.specialPermissions).toEqual([]); + expect(copy.body.role.permissionCount).toBe(2); + expect(copy.body.role.specialPermissionCount).toBe(0); + + // 来源角色保持原样 + const sourceAfter = await callJson( + created.app, + 'GET', + `/roles/${source.id}`, + token, + ); + expect(sourceAfter.body.specialPermissions).toEqual(['audit:export', 'delivery:export']); + }); + + // ------------------------------------------------------------------------- + // 4. 权限变更即时生效(同 access token,下一次请求即变化) + // ------------------------------------------------------------------------- + + it('即时生效:改权限 / 授特殊权限 / 停用 / 恢复后,绑定用户下一次请求立即变化', async () => { + const adminLogin = await login(created.app, manager.account); + const token = adminLogin.body.accessToken; + const role = await createTestRole({ + name: '即时生效', + permissions: ['user:view'], + dataScope: { type: 'department' }, + }); + const user = await createFixtureUserWithRoleId('realtime', role.id); + const userLogin = await login(created.app, user.account); + const userToken = userLogin.body.accessToken; + + const before = await getMe(created.app, userToken); + expect(before.status).toBe(200); + expect(permissionsSet(before.body)).toContain('user:view'); + expect( + (await callJson<{ items: unknown[] }>(created.app, 'GET', '/users', userToken)).status, + ).toBe(200); + + // 改权限(普通):user:view → role:view,同一 access token 下一次请求即变化 + const patched = await callJson( + created.app, + 'PATCH', + `/roles/${role.id}`, + token, + { permissions: ['role:view'] }, + ); + expect(patched.status).toBe(200); + expect(patched.body.affectedUserCount).toBe(1); + + const afterPatch = await getMe(created.app, userToken); + expect(afterPatch.status).toBe(200); + expect(permissionsSet(afterPatch.body)).toContain('role:view'); + expect(permissionsSet(afterPatch.body)).not.toContain('user:view'); + const usersDenied = await callJson(created.app, 'GET', '/users', userToken); + expect(usersDenied.status).toBe(403); + expect(usersDenied.body.code).toBe(ErrorCode.PERMISSION_DENIED); + expect( + (await callJson<{ totalPermissions: number }>(created.app, 'GET', '/permissions', userToken)) + .status, + ).toBe(200); + + // 授予特殊权限:/me 立即包含该编码(与普通权限同一解析链) + const specialGranted = await callJson( + created.app, + 'PATCH', + `/roles/${role.id}`, + token, + { specialPermissions: ['delivery:export'] }, + ); + expect(specialGranted.status).toBe(200); + const afterSpecial = await getMe(created.app, userToken); + expect(permissionsSet(afterSpecial.body)).toContain('delivery:export'); + expect(permissionsSet(afterSpecial.body)).toContain('role:view'); + + // 停用:下一次请求权限清空(40301),恢复后同一 token 再次可用 + const disabled = await callJson( + created.app, + 'PATCH', + `/roles/${role.id}`, + token, + { status: 'disabled', reason: 'A2-QA-01 即时生效用例' }, + ); + expect(disabled.status).toBe(200); + expect(disabled.body.affectedUserCount).toBe(1); + const afterDisable = await getMe(created.app, userToken); + expect(afterDisable.status).toBe(200); + expect(afterDisable.body.permissions).toEqual([]); + const denyAfterDisable = await callJson( + created.app, + 'GET', + '/permissions', + userToken, + ); + expect(denyAfterDisable.status).toBe(403); + expect(denyAfterDisable.body.code).toBe(ErrorCode.PERMISSION_DENIED); + + const enabled = await callJson( + created.app, + 'PATCH', + `/roles/${role.id}`, + token, + { status: 'enabled' }, + ); + expect(enabled.status).toBe(200); + const afterEnable = await getMe(created.app, userToken); + expect(afterEnable.status).toBe(200); + expect(permissionsSet(afterEnable.body)).toContain('role:view'); + expect(permissionsSet(afterEnable.body)).toContain('delivery:export'); + expect( + (await callJson<{ items: unknown[] }>(created.app, 'GET', '/permissions', userToken)).status, + ).toBe(200); + }); + + it('特殊权限变更审计:outbox role.permissions.changed 含 specialPermissions diff 与影响用户', async () => { + const adminLogin = await login(created.app, manager.account); + const token = adminLogin.body.accessToken; + const role = await createTestRole({ + name: '特殊权限审计', + permissions: ['user:view'], + dataScope: { type: 'all' }, + }); + const user = await createFixtureUserWithRoleId('special-audit-log', role.id); + + const patched = await callJson( + created.app, + 'PATCH', + `/roles/${role.id}`, + token, + { specialPermissions: ['audit:read'] }, + ); + expect(patched.status).toBe(200); + expect(patched.body.role.specialPermissions).toEqual(['audit:read']); + + const outbox = await prisma.outboxEvent.findFirst({ + where: { aggregateId: role.id, eventType: 'role.permissions.changed' }, + orderBy: { occurredAt: 'desc' }, + }); + expect(outbox).not.toBeNull(); + const payload = outbox?.payloadJson as { + affectedUserIds: string[]; + changes: { specialPermissions: { added: string[]; removed: string[] } }; + }; + expect(payload.affectedUserIds).toEqual([user.id]); + expect(payload.changes.specialPermissions.added).toEqual(['audit:read']); + expect(payload.changes.specialPermissions.removed).toEqual([]); + }); +}); diff --git a/apps/api/src/modules/identity/roles.service.spec.ts b/apps/api/src/modules/identity/roles.service.spec.ts new file mode 100644 index 0000000..4a39aa7 --- /dev/null +++ b/apps/api/src/modules/identity/roles.service.spec.ts @@ -0,0 +1,328 @@ +import { ErrorCode } from '@lingxi/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { AppException } from '../../common/errors/app-exception'; +import type { RequestContextService } from '../../common/security/request-context'; +import type { PrismaService } from '../../infra/prisma/prisma.service'; +import { RolesService } from './roles.service'; + +const NOW = new Date('2026-11-03T02:00:00.000Z'); + +function roleFixture(overrides: Record = {}): Record { + return { + id: 'r1', + code: 'copywriter', + name: '文案策划', + description: '脚本撰写', + isSystem: false, + status: 'ACTIVE', + createdAt: NOW, + updatedAt: NOW, + permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'script:write' }], + dataScopes: [{ scopeType: 'department', targetId: null }], + users: [ + { + createdAt: NOW, + user: { + id: 'u1', + name: '周岸', + account: 'zhou.an', + phone: '13755205520', + status: 'ACTIVE', + department: { name: '素材二组' }, + }, + }, + { + createdAt: NOW, + user: { + id: 'u2', + name: '林清', + account: 'lin.qing', + phone: '13912347712', + status: 'DISABLED', + department: { name: '设计组' }, + }, + }, + ], + _count: { users: 2 }, + ...overrides, + }; +} + +interface Harness { + service: RolesService; + prisma: { + role: { + findFirst: ReturnType; + findUnique: ReturnType; + count: ReturnType; + }; + user: { count: ReturnType }; + $transaction: ReturnType; + tx: { + role: { + create: ReturnType; + update: ReturnType; + delete: ReturnType; + }; + rolePermission: { + deleteMany: ReturnType; + createMany: ReturnType; + }; + dataScope: { deleteMany: ReturnType; createMany: ReturnType }; + outboxEvent: { create: ReturnType }; + }; + }; + requestContext: { get: ReturnType; patchAudit: ReturnType }; +} + +function harness(): Harness { + const tx = { + role: { create: vi.fn().mockResolvedValue({}), update: vi.fn(), delete: vi.fn() }, + rolePermission: { deleteMany: vi.fn(), createMany: vi.fn() }, + dataScope: { deleteMany: vi.fn(), createMany: vi.fn() }, + outboxEvent: { create: vi.fn().mockResolvedValue({}) }, + }; + const prisma = { + role: { + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + count: vi.fn().mockResolvedValue(9), + }, + user: { count: vi.fn().mockResolvedValue(2) }, + $transaction: vi.fn(async (callback: unknown) => { + if (typeof callback === 'function') { + return (callback as (client: typeof tx) => Promise)(tx); + } + return Promise.all(callback as Promise[]); + }), + tx, + }; + const requestContext = { + get: vi.fn().mockReturnValue({ traceId: 'trace-1' }), + patchAudit: vi.fn(), + }; + return { + service: new RolesService( + prisma as unknown as PrismaService, + requestContext as unknown as RequestContextService, + ), + prisma, + requestContext, + }; +} + +function expectRejected(promise: Promise, code: ErrorCode): Promise { + return promise.then( + () => expect.unreachable('应抛出异常'), + (error: unknown) => { + expect(error).toBeInstanceOf(AppException); + expect((error as AppException).code).toBe(code); + }, + ); +} + +describe('RolesService.create', () => { + it('默认全空:code 自动生成、权限点为空、写审计与 outbox', async () => { + const { service, prisma, requestContext } = harness(); + prisma.role.findUnique.mockImplementation((args: { where: { id: string } }) => + Promise.resolve( + roleFixture({ id: args.where.id, code: 'custom_x', permissions: [], users: [] }), + ), + ); + const result = await service.create( + { name: '新运营组', dataScope: { type: 'created_by_me' } }, + 'actor-1', + ); + const createdArgs = prisma.tx.role.create.mock.calls[0]?.[0] as { data: { code: string } }; + expect(createdArgs.data.code).toMatch(/^custom_[0-9a-z]{26}$/); + expect(result.role.permissions).toEqual([]); + expect(result.affectedUserCount).toBe(0); + expect(result.warnings).toEqual([]); + + const createArgs = prisma.tx.role.create.mock.calls[0]?.[0] as { + data: { code: string; permissions: { create: unknown[] } }; + }; + expect(createArgs.data.permissions.create).toEqual([]); + const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as { + data: { eventType: string; payloadJson: { action: string; roleId: string } }; + }; + expect(outboxArgs.data.eventType).toBe('role.permissions.changed'); + expect(outboxArgs.data.payloadJson.action).toBe('created'); + expect(requestContext.patchAudit).toHaveBeenCalledWith( + expect.objectContaining({ targetType: 'role', affectedUserCount: 0 }), + ); + }); + + it('角色名称重复返回 40902', async () => { + const { service, prisma } = harness(); + prisma.role.findFirst.mockResolvedValueOnce({ id: 'other' }); + await expectRejected( + service.create({ name: '文案策划', dataScope: { type: 'all' } }, 'actor-1'), + ErrorCode.DUPLICATE_SUBMIT, + ); + }); + + it('SoD 命中返回 40304 并给出冲突对', async () => { + const { service } = harness(); + let thrown: unknown; + try { + await service.create( + { + name: '冲突角色', + permissions: ['script:write', 'script:review'], + dataScope: { type: 'department' }, + }, + 'actor-1', + ); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(AppException); + expect((thrown as AppException).code).toBe(ErrorCode.SOD_VIOLATION); + expect((thrown as AppException).details).toMatchObject({ + conflicts: [{ pairId: 'script-write-review' }], + }); + }); + + it('特殊权限混入 permissions 返回 40001', async () => { + const { service } = harness(); + await expectRejected( + service.create( + { + name: '导出角色', + permissions: ['delivery:export'], + dataScope: { type: 'all' }, + }, + 'actor-1', + ), + ErrorCode.PARAM_INVALID, + ); + }); +}); + +describe('RolesService.update', () => { + it('系统角色仅允许改显示名:改权限 / 状态返回 40903,改名放行', async () => { + const { service, prisma } = harness(); + const systemRole = roleFixture({ code: 'super_admin', isSystem: true }); + prisma.role.findUnique.mockResolvedValueOnce(systemRole); + await expectRejected( + service.update('r1', { permissions: ['user:view'] }, 'actor-1'), + ErrorCode.STATE_NOT_ALLOWED, + ); + prisma.role.findUnique.mockResolvedValueOnce(systemRole); + await expectRejected( + service.update('r1', { status: 'disabled' }, 'actor-1'), + ErrorCode.STATE_NOT_ALLOWED, + ); + + prisma.role.findUnique + .mockResolvedValueOnce(systemRole) + .mockResolvedValueOnce( + roleFixture({ code: 'super_admin', isSystem: true, name: '新超管名' }), + ); + const result = await service.update('r1', { name: '新超管名' }, 'actor-1'); + expect(result.role.name).toBe('新超管名'); + }); + + it('更新权限:返回受影响用户数、outbox 携带 diff、审计含前后值', async () => { + const { service, prisma, requestContext } = harness(); + prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce( + roleFixture({ + permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'user:view' }], + }), + ); + const result = await service.update( + 'r1', + { permissions: ['script:read', 'user:view'], status: 'disabled', reason: '岗位合并' }, + 'actor-1', + ); + expect(result.affectedUserCount).toBe(2); + expect(result.role.specialPermissions).toEqual([]); + + const updateArgs = prisma.tx.role.update.mock.calls[0]?.[0] as { data: { status: string } }; + expect(updateArgs.data.status).toBe('DISABLED'); + const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as { + data: { + payloadJson: { + changes: { permissions: { added: string[]; removed: string[] } }; + affectedUserIds: string[]; + }; + }; + }; + expect(outboxArgs.data.payloadJson.changes.permissions.added).toEqual(['user:view']); + expect(outboxArgs.data.payloadJson.changes.permissions.removed).toEqual(['script:write']); + expect(outboxArgs.data.payloadJson.affectedUserIds).toEqual(['u1', 'u2']); + expect(requestContext.patchAudit).toHaveBeenCalledWith( + expect.objectContaining({ + affectedUserCount: 2, + affectedUserIds: ['u1', 'u2'], + reason: '岗位合并', + }), + ); + }); + + it('更新数据范围:替换 data_scopes 行;customer_groups 多个客户组写多行', async () => { + const { service, prisma } = harness(); + prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce( + roleFixture({ + dataScopes: [ + { scopeType: 'customer_groups', targetId: 'g1' }, + { scopeType: 'customer_groups', targetId: 'g2' }, + ], + }), + ); + const groups = ['01HZZZZZZZZZZZZZZZZZZZZZZ1', '01HZZZZZZZZZZZZZZZZZZZZZZ2']; + const result = await service.update( + 'r1', + { dataScope: { type: 'customer_groups', targetIds: groups } }, + 'actor-1', + ); + expect(result.role.dataScope).toEqual({ type: 'customer_groups', targetIds: ['g1', 'g2'] }); + const createManyArgs = prisma.tx.dataScope.createMany.mock.calls[0]?.[0] as { + data: { roleId: string; scopeType: string; targetId: string }[]; + }; + expect(createManyArgs.data).toHaveLength(2); + expect(createManyArgs.data[0]?.scopeType).toBe('customer_groups'); + }); +}); + +describe('RolesService.remove', () => { + it('系统角色不可删除(40903)', async () => { + const { service, prisma } = harness(); + prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ isSystem: true })); + await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED); + }); + + it('有绑定用户只能停用(40903,附 userCount)', async () => { + const { service, prisma } = harness(); + prisma.role.findUnique.mockResolvedValueOnce(roleFixture()); + await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED); + }); + + it('无绑定用户删除成功并写 outbox(先事件后删除)', async () => { + const { service, prisma } = harness(); + prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ users: [], _count: { users: 0 } })); + await service.remove('r1', 'actor-1'); + expect(prisma.tx.role.delete).toHaveBeenCalledWith({ where: { id: 'r1' } }); + expect(prisma.tx.outboxEvent.create).toHaveBeenCalledTimes(1); + }); +}); + +describe('RolesService.detail', () => { + it('角色不存在返回 40401', async () => { + const { service } = harness(); + await expectRejected(service.detail('missing'), ErrorCode.NOT_FOUND); + }); + + it('超管详情隐式展示全部权限点(30)与 all 范围', async () => { + const { service, prisma } = harness(); + prisma.role.findUnique.mockResolvedValueOnce( + roleFixture({ code: 'super_admin', isSystem: true, permissions: [], dataScopes: [] }), + ); + const detail = await service.detail('r1'); + expect(detail.permissions).toHaveLength(24); + expect(detail.specialPermissions).toHaveLength(6); + expect(detail.dataScope).toEqual({ type: 'all' }); + }); +}); diff --git a/apps/api/src/modules/identity/roles.service.ts b/apps/api/src/modules/identity/roles.service.ts index 327fc0f..a041696 100644 --- a/apps/api/src/modules/identity/roles.service.ts +++ b/apps/api/src/modules/identity/roles.service.ts @@ -1,20 +1,749 @@ -import { type RoleSummary } from '@lingxi/shared'; +import { + type CreateRoleInput, + DATA_SCOPE_TYPES, + type DataScope, + type DataScopeType, + ErrorCode, + findSodConflicts, + isPermissionCode, + isSpecialPermission, + type ListResponse, + PERMISSION_CODES, + type PermissionCode, + ROLE_SOFT_LIMIT, + type RoleBoundUser, + type RoleDetail, + type RoleListItem, + type RoleListQuery, + type RoleSaveResult, + splitPermissions, + SUPER_ADMIN_SOFT_LIMIT, + ulid, + type UpdateRoleInput, +} from '@lingxi/shared'; import { Inject, Injectable } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { AppException } from '../../common/errors/app-exception'; +import { RequestContextService } from '../../common/security/request-context'; import type { PrismaService } from '../../infra/prisma/prisma.service'; import { PRISMA } from '../../infra/tokens'; -import { toRoleSummary } from './user.mapper'; +import { iso, maskPhone } from './user.mapper'; -/** 角色列表:A1 只做绑定多选,完整角色管理属 A2 */ +const ROLE_LIST_INCLUDE = { + permissions: { select: { permissionCode: true } }, + dataScopes: { select: { scopeType: true, targetId: true } }, + _count: { select: { users: true } }, +} satisfies Prisma.RoleInclude; + +const ROLE_DETAIL_INCLUDE = { + permissions: { select: { permissionCode: true } }, + dataScopes: { select: { scopeType: true, targetId: true } }, + users: { + select: { + createdAt: true, + user: { + select: { + id: true, + name: true, + account: true, + phone: true, + status: true, + department: { select: { name: true } }, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }, + _count: { select: { users: true } }, +} satisfies Prisma.RoleInclude; + +type RoleWithList = Prisma.RoleGetPayload<{ include: typeof ROLE_LIST_INCLUDE }>; +type RoleWithDetail = Prisma.RoleGetPayload<{ include: typeof ROLE_DETAIL_INCLUDE }>; + +interface RolePermissionSnapshot { + permissions: PermissionCode[]; + specialPermissions: PermissionCode[]; + dataScope: DataScope; + status: 'active' | 'disabled'; +} + +interface PermissionDiff { + added: PermissionCode[]; + removed: PermissionCode[]; + specialAdded: PermissionCode[]; + specialRemoved: PermissionCode[]; +} + +interface CreatePersistArgs { + id: string; + code: string; + input: CreateRoleInput; + permissions: PermissionCode[]; + specialPermissions: PermissionCode[]; + diff: PermissionDiff; + actorId: string; + traceId: string | null; +} + +interface UpdatePersistArgs { + id: string; + existing: RoleWithDetail; + input: UpdateRoleInput; + after: RolePermissionSnapshot; + diff: PermissionDiff; + affectedUserIds: string[]; + nextName: string; + nextDescription: string; + beforeDataScopeType: string; + actorId: string; + traceId: string | null; +} + +interface OutboxEventArgs { + action: 'created' | 'updated' | 'deleted'; + roleId: string; + roleCode: string; + roleName: string; + actorId: string; + traceId: string | null; + diff: PermissionDiff; + affectedUserIds: string[]; + dataScopeBefore: string | null; + dataScopeAfter: string | null; +} + +function splitCodes(codes: readonly PermissionCode[]): { + normal: PermissionCode[]; + special: PermissionCode[]; +} { + const normal: PermissionCode[] = []; + const special: PermissionCode[] = []; + for (const code of codes) { + if (isSpecialPermission(code)) { + special.push(code); + } else { + normal.push(code); + } + } + return { normal: normal.sort(), special: special.sort() }; +} + +function effectiveCodes(role: { + isSystem: boolean; + permissions: { permissionCode: string }[]; +}): PermissionCode[] { + if (role.isSystem) { + return [...PERMISSION_CODES]; + } + return role.permissions + .map((item) => item.permissionCode) + .filter(isPermissionCode) + .sort(); +} + +/** 一个角色只允许一种范围类型(A2 契约):多行(customer_groups 多客户组)聚合为单值 */ +function aggregateDataScope( + rows: readonly { scopeType: string; targetId: string | null }[], + isSystem: boolean, +): DataScope { + if (isSystem) { + return { type: 'all' }; + } + if (rows.length === 0) { + return { type: 'created_by_me' }; + } + const ordered = [...rows].sort( + (left, right) => + DATA_SCOPE_TYPES.indexOf(left.scopeType as DataScopeType) - + DATA_SCOPE_TYPES.indexOf(right.scopeType as DataScopeType), + ); + const type = (ordered[0]?.scopeType ?? 'created_by_me') as DataScopeType; + if (type !== 'customer_groups') { + return { type }; + } + const targetIds = [ + ...new Set( + rows + .filter((row) => row.scopeType === type) + .flatMap((row) => (row.targetId === null ? [] : [row.targetId])), + ), + ].sort(); + return targetIds.length > 0 ? { type, targetIds } : { type }; +} + +function toBoundUser(row: RoleWithDetail['users'][number]): RoleBoundUser { + return { + id: row.user.id, + name: row.user.name, + account: row.user.account, + phoneMasked: maskPhone(row.user.phone), + departmentName: row.user.department.name, + status: row.user.status === 'ACTIVE' ? 'active' : 'disabled', + }; +} + +function toListItem(role: RoleWithList): RoleListItem { + const codes = splitCodes(effectiveCodes(role)); + return { + id: role.id, + code: role.code, + name: role.name, + description: role.description, + isSystem: role.isSystem, + status: role.status === 'ACTIVE' ? 'active' : 'disabled', + permissionCount: codes.normal.length, + specialPermissionCount: codes.special.length, + userCount: role._count.users, + dataScope: aggregateDataScope(role.dataScopes, role.isSystem), + createdAt: iso(role.createdAt), + updatedAt: iso(role.updatedAt), + }; +} + +function toDetail(role: RoleWithDetail): RoleDetail { + const listItem = toListItem(role); + const codes = splitCodes(effectiveCodes(role)); + return { + ...listItem, + permissions: codes.normal, + specialPermissions: codes.special, + boundUsers: role.users.map((row) => toBoundUser(row)), + }; +} + +function diffCodes(before: RolePermissionSnapshot, after: RolePermissionSnapshot): PermissionDiff { + const beforeNormal = new Set(before.permissions); + const afterNormal = new Set(after.permissions); + const beforeSpecial = new Set(before.specialPermissions); + const afterSpecial = new Set(after.specialPermissions); + return { + added: after.permissions.filter((code) => !beforeNormal.has(code)).sort(), + removed: before.permissions.filter((code) => !afterNormal.has(code)).sort(), + specialAdded: after.specialPermissions.filter((code) => !beforeSpecial.has(code)).sort(), + specialRemoved: before.specialPermissions.filter((code) => !afterSpecial.has(code)).sort(), + }; +} + +/** + * 角色管理(A2-BE-01 / 03 / 05): + * - 列表 / 详情(目录统计 + 数据范围 + 绑定用户); + * - 新建(权限默认全空、code 自动生成、SoD 保存即校验); + * - 更新(含启用 / 停用;系统角色仅显示名可改); + * - 删除(系统角色拒绝;有绑定用户 40903); + * - 变更审计(前后值 diff + 影响用户数)与 outbox `role.permissions.changed` 占位通知。 + */ @Injectable() export class RolesService { - constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} + constructor( + @Inject(PRISMA) private readonly prisma: PrismaService, + @Inject(RequestContextService) private readonly requestContext: RequestContextService, + ) {} - async list(): Promise { - const roles = await this.prisma.role.findMany({ - where: { status: 'ACTIVE' }, - orderBy: [{ isSystem: 'desc' }, { name: 'asc' }], + async list(query: RoleListQuery): Promise> { + const where: Prisma.RoleWhereInput = {}; + if (query.status !== 'all') { + where.status = query.status === 'active' ? 'ACTIVE' : 'DISABLED'; + } + if (query.keyword !== undefined && query.keyword.length > 0) { + where.OR = [ + { name: { contains: query.keyword, mode: 'insensitive' } }, + { code: { contains: query.keyword, mode: 'insensitive' } }, + ]; + } + const direction: Prisma.SortOrder = query.order === 'asc' ? 'asc' : 'desc'; + const orderBy: Prisma.RoleOrderByWithRelationInput[] = + query.sort === 'name' + ? [{ name: direction }, { id: 'asc' }] + : [{ createdAt: direction }, { id: 'asc' }]; + const rows = await this.prisma.role.findMany({ + where, + orderBy, + take: query.limit + 1, + include: ROLE_LIST_INCLUDE, + ...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }), }); - return roles.map((role) => toRoleSummary(role)); + const page = rows.slice(0, query.limit); + const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null; + return { items: page.map((row) => toListItem(row)), next_cursor: next }; + } + + async detail(id: string): Promise { + const role = await this.prisma.role.findUnique({ + where: { id }, + include: ROLE_DETAIL_INCLUDE, + }); + if (role === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' }); + } + return toDetail(role); + } + + async create(input: CreateRoleInput, actorId: string): Promise { + await this.ensureNameAvailable(input.name); + const { permissions, specialPermissions } = this.validatePermissions( + input.permissions ?? [], + input.specialPermissions ?? [], + ); + this.assertSod({ + permissions, + specialPermissions, + dataScope: input.dataScope, + status: 'active', + }); + const id = ulid(); + const traceId = this.requestContext.get()?.traceId ?? null; + const diff: PermissionDiff = { + added: permissions, + removed: [], + specialAdded: specialPermissions, + specialRemoved: [], + }; + const code = `custom_${ulid().toLowerCase()}`; + await this.prisma.$transaction((tx) => + this.persistCreate(tx, { + id, + code, + input, + permissions, + specialPermissions, + diff, + actorId, + traceId, + }), + ); + const role = await this.detail(id); + this.recordCreateAudit(role, diff); + return { role, affectedUserCount: 0, warnings: await this.buildWarnings(role.code) }; + } + + async update(id: string, input: UpdateRoleInput, actorId: string): Promise { + const existing = await this.prisma.role.findUnique({ + where: { id }, + include: ROLE_DETAIL_INCLUDE, + }); + if (existing === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' }); + } + this.assertSystemRoleMutable(existing, input); + if (input.name !== undefined && input.name !== existing.name) { + await this.ensureNameAvailable(input.name, id); + } + const before = this.snapshotOf(existing); + const after = this.mergeSnapshot(before, input); + // 超管豁免 SoD(系统角色不可改权限,隐式全集必然「违反」互斥对) + if (!existing.isSystem) { + this.assertSod(after); + } + const diff = diffCodes(before, after); + const affectedUserIds = existing.users.map((row) => row.user.id); + await this.prisma.$transaction((tx) => + this.persistUpdate(tx, { + id, + existing, + input, + after, + diff, + affectedUserIds, + nextName: input.name ?? existing.name, + nextDescription: input.description ?? existing.description, + beforeDataScopeType: before.dataScope.type, + actorId, + traceId: this.requestContext.get()?.traceId ?? null, + }), + ); + const role = await this.detail(id); + this.recordUpdateAudit({ existing, before, role, affectedUserIds, diff, input }); + return { + role, + affectedUserCount: affectedUserIds.length, + warnings: await this.buildWarnings(role.code), + }; + } + + async remove(id: string, actorId: string): Promise { + const existing = await this.prisma.role.findUnique({ + where: { id }, + include: { _count: { select: { users: true } } }, + }); + if (existing === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' }); + } + if (existing.isSystem) { + throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '系统角色不可删除' }); + } + if (existing._count.users > 0) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: `角色已绑定 ${String(existing._count.users)} 名用户,只能停用`, + details: { userCount: existing._count.users }, + }); + } + await this.prisma.$transaction((tx) => + this.persistRemove(tx, { + id, + existing, + actorId, + traceId: this.requestContext.get()?.traceId ?? null, + }), + ); + this.requestContext.patchAudit({ + targetType: 'role', + targetId: id, + before: { name: existing.name, code: existing.code, status: existing.status }, + after: null, + affectedUserCount: 0, + affectedUserIds: [], + }); + } + + private async persistCreate( + tx: Prisma.TransactionClient, + args: CreatePersistArgs, + ): Promise { + await tx.role.create({ + data: { + id: args.id, + code: args.code, + name: args.input.name, + description: args.input.description ?? '', + isSystem: false, + status: 'ACTIVE', + permissions: { + create: [...args.permissions, ...args.specialPermissions].map((permissionCode) => ({ + permissionCode, + })), + }, + dataScopes: { + create: this.dataScopeRows( + args.input.dataScope.type, + args.input.dataScope.targetIds ?? [], + ), + }, + }, + }); + await this.writeOutboxEvent(tx, { + action: 'created', + roleId: args.id, + roleCode: args.code, + roleName: args.input.name, + actorId: args.actorId, + traceId: args.traceId, + diff: args.diff, + affectedUserIds: [], + dataScopeBefore: null, + dataScopeAfter: args.input.dataScope.type, + }); + } + + private recordCreateAudit(role: RoleDetail, diff: PermissionDiff): void { + this.requestContext.patchAudit({ + targetType: 'role', + targetId: role.id, + before: null, + after: this.auditSnapshot(role), + affectedUserCount: 0, + affectedUserIds: [], + changes: diff, + }); + } + + private async persistUpdate( + tx: Prisma.TransactionClient, + args: UpdatePersistArgs, + ): Promise { + await this.applyRoleUpdate(tx, args); + await this.writeOutboxEvent(tx, { + action: 'updated', + roleId: args.id, + roleCode: args.existing.code, + roleName: args.nextName, + actorId: args.actorId, + traceId: args.traceId, + diff: args.diff, + affectedUserIds: args.affectedUserIds, + dataScopeBefore: + args.existing.dataScopes.length > 0 + ? (args.existing.dataScopes[0]?.scopeType ?? null) + : null, + dataScopeAfter: args.after.dataScope.type, + }); + } + + private async applyRoleUpdate( + tx: Prisma.TransactionClient, + args: UpdatePersistArgs, + ): Promise { + await tx.role.update({ + where: { id: args.id }, + data: { + name: args.nextName, + description: args.nextDescription, + status: args.after.status === 'active' ? 'ACTIVE' : 'DISABLED', + }, + }); + if (args.input.permissions !== undefined || args.input.specialPermissions !== undefined) { + await tx.rolePermission.deleteMany({ where: { roleId: args.id } }); + await tx.rolePermission.createMany({ + data: [...args.after.permissions, ...args.after.specialPermissions].map( + (permissionCode) => ({ roleId: args.id, permissionCode }), + ), + }); + } + if (args.input.dataScope !== undefined) { + await tx.dataScope.deleteMany({ where: { roleId: args.id } }); + await tx.dataScope.createMany({ + data: this.dataScopeRows( + args.input.dataScope.type, + args.input.dataScope.targetIds ?? [], + ).map((row) => ({ ...row, roleId: args.id })), + }); + } + } + + private recordUpdateAudit(args: { + existing: RoleWithDetail; + before: RolePermissionSnapshot; + role: RoleDetail; + affectedUserIds: string[]; + diff: PermissionDiff; + input: UpdateRoleInput; + }): void { + this.requestContext.patchAudit({ + targetType: 'role', + targetId: args.role.id, + before: { + name: args.existing.name, + description: args.existing.description, + permissions: args.before.permissions, + specialPermissions: args.before.specialPermissions, + dataScope: args.before.dataScope, + status: args.before.status, + }, + after: this.auditSnapshot(args.role), + affectedUserCount: args.affectedUserIds.length, + affectedUserIds: args.affectedUserIds, + changes: args.diff, + ...(args.input.reason === undefined ? {} : { reason: args.input.reason }), + }); + } + + private async persistRemove( + tx: Prisma.TransactionClient, + args: { + id: string; + existing: { code: string; name: string }; + actorId: string; + traceId: string | null; + }, + ): Promise { + await this.writeOutboxEvent(tx, { + action: 'deleted', + roleId: args.id, + roleCode: args.existing.code, + roleName: args.existing.name, + actorId: args.actorId, + traceId: args.traceId, + diff: { added: [], removed: [], specialAdded: [], specialRemoved: [] }, + affectedUserIds: [], + dataScopeBefore: null, + dataScopeAfter: null, + }); + await tx.role.delete({ where: { id: args.id } }); + } + + private async writeOutboxEvent( + tx: Prisma.TransactionClient, + args: OutboxEventArgs, + ): Promise { + await tx.outboxEvent.create({ + data: { + id: ulid(), + aggregateType: 'role', + aggregateId: args.roleId, + eventType: 'role.permissions.changed', + payloadJson: this.eventPayload(args), + ...(args.traceId === null ? {} : { traceId: args.traceId }), + }, + }); + } + + private mergeSnapshot( + before: RolePermissionSnapshot, + input: UpdateRoleInput, + ): RolePermissionSnapshot { + return { + permissions: + input.permissions === undefined + ? before.permissions + : this.validatePermissions(input.permissions, []).permissions, + specialPermissions: + input.specialPermissions === undefined + ? before.specialPermissions + : this.validatePermissions([], input.specialPermissions).specialPermissions, + dataScope: input.dataScope ?? before.dataScope, + status: + input.status === undefined + ? before.status + : input.status === 'enabled' + ? 'active' + : 'disabled', + }; + } + + private auditSnapshot(role: RoleDetail): Record { + return { + name: role.name, + permissions: role.permissions, + specialPermissions: role.specialPermissions, + dataScope: role.dataScope, + status: role.status, + }; + } + + private snapshotOf(role: RoleWithDetail): RolePermissionSnapshot { + const codes = splitCodes(effectiveCodes(role)); + return { + permissions: codes.normal, + specialPermissions: codes.special, + dataScope: aggregateDataScope(role.dataScopes, role.isSystem), + status: role.status === 'ACTIVE' ? 'active' : 'disabled', + }; + } + + private assertSystemRoleMutable(role: { isSystem: boolean }, input: UpdateRoleInput): void { + if (!role.isSystem) { + return; + } + const touchesProtected = + input.description !== undefined || + input.permissions !== undefined || + input.specialPermissions !== undefined || + input.dataScope !== undefined || + input.status !== undefined; + if (touchesProtected) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '系统角色不可修改权限 / 数据范围 / 状态,仅显示名可改', + }); + } + } + + private validatePermissions( + normalInput: readonly string[], + specialInput: readonly string[], + ): { permissions: PermissionCode[]; specialPermissions: PermissionCode[] } { + let parsedNormal: PermissionCode[]; + let parsedSpecial: PermissionCode[]; + try { + const combined = splitPermissions([...normalInput, ...specialInput]); + parsedNormal = combined.permissions; + parsedSpecial = combined.specialPermissions; + } catch { + throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在未登记的权限点编码' }); + } + const normalSet = new Set(parsedNormal); + for (const code of normalInput) { + if (!normalSet.has(code)) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: `特殊权限 ${code} 必须通过 specialPermissions 单独授予`, + }); + } + } + const specialSet = new Set(parsedSpecial); + for (const code of specialInput) { + if (!specialSet.has(code)) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: `普通权限 ${code} 不能放入 specialPermissions`, + }); + } + } + return { permissions: parsedNormal, specialPermissions: parsedSpecial }; + } + + private assertSod(snapshot: RolePermissionSnapshot): void { + const conflicts = findSodConflicts([...snapshot.permissions, ...snapshot.specialPermissions]); + if (conflicts.length > 0) { + throw new AppException({ + code: ErrorCode.SOD_VIOLATION, + message: `违反职责互斥:${conflicts.map((conflict) => conflict.label).join(';')}`, + details: { conflicts }, + }); + } + } + + private dataScopeRows( + type: DataScopeType, + targetIds: readonly string[], + ): { id: string; scopeType: string; targetId: string | null }[] { + if (type !== 'customer_groups' || targetIds.length === 0) { + return [{ id: ulid(), scopeType: type, targetId: null }]; + } + return targetIds.map((targetId) => ({ id: ulid(), scopeType: type, targetId })); + } + + private async ensureNameAvailable(name: string, excludeId?: string): Promise { + const existing = await this.prisma.role.findFirst({ + where: { name, ...(excludeId === undefined ? {} : { NOT: { id: excludeId } }) }, + select: { id: true }, + }); + if (existing !== null) { + throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '角色名称已存在' }); + } + } + + /** 软提示(不阻断):角色数量 / 超管人数超过 PRD 建议值;A4 迁 system_configs */ + private async buildWarnings(roleCode: string): Promise { + const warnings: string[] = []; + const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } }); + if (activeRoleCount > ROLE_SOFT_LIMIT) { + warnings.push( + `启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(ROLE_SOFT_LIMIT)},建议合并角色`, + ); + } + if (roleCode === 'super_admin') { + const superAdminCount = await this.prisma.user.count({ + where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } }, + }); + if (superAdminCount > SUPER_ADMIN_SOFT_LIMIT) { + warnings.push( + `超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人`, + ); + } + } + return warnings; + } + + private eventPayload(input: { + action: 'created' | 'updated' | 'deleted'; + roleId: string; + roleCode: string; + roleName: string; + actorId: string; + traceId: string | null; + diff: PermissionDiff; + affectedUserIds: string[]; + dataScopeBefore: string | null; + dataScopeAfter: string | null; + }): Prisma.InputJsonObject { + return { + action: input.action, + roleId: input.roleId, + roleCode: input.roleCode, + roleName: input.roleName, + actorId: input.actorId, + traceId: input.traceId, + changes: { + permissions: { added: input.diff.added, removed: input.diff.removed }, + specialPermissions: { + added: input.diff.specialAdded, + removed: input.diff.specialRemoved, + }, + dataScope: { before: input.dataScopeBefore, after: input.dataScopeAfter }, + }, + affectedUserIds: input.affectedUserIds, + }; } } diff --git a/apps/api/src/modules/identity/users.service.ts b/apps/api/src/modules/identity/users.service.ts index b4bb7da..934018c 100644 --- a/apps/api/src/modules/identity/users.service.ts +++ b/apps/api/src/modules/identity/users.service.ts @@ -4,13 +4,14 @@ import { ErrorCode, type ListResponse, Permission, + SUPER_ADMIN_SOFT_LIMIT, ulid, type UpdateUserInput, type UserDetail, type UserListQuery, type UserSummary, } from '@lingxi/shared'; -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { AppException } from '../../common/errors/app-exception'; @@ -31,6 +32,8 @@ function hasPermission(permissions: string[], code: string): boolean { /** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */ @Injectable() export class UsersService { + private readonly logger = new Logger('UsersService'); + constructor( @Inject(PRISMA) private readonly prisma: PrismaService, @Inject(PasswordService) private readonly passwords: PasswordService, @@ -90,6 +93,7 @@ export class UsersService { } catch (error) { throw this.mapUniqueError(error); } + await this.warnIfSuperAdminExceeded(input.roleIds); return this.detail(id, permissions); } @@ -125,6 +129,9 @@ export class UsersService { } catch (error) { throw this.mapUniqueError(error); } + if (input.roleIds !== undefined) { + await this.warnIfSuperAdminExceeded(input.roleIds); + } return this.detail(id, permissions); } @@ -181,6 +188,28 @@ export class UsersService { }); } + /** + * 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存; + * 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。 + */ + private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise { + const superAdminRole = await this.prisma.role.findUnique({ + where: { code: 'super_admin' }, + select: { id: true }, + }); + if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) { + return; + } + const count = await this.prisma.user.count({ + where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } }, + }); + if (count > SUPER_ADMIN_SOFT_LIMIT) { + this.logger.warn( + `超级管理员 ${String(count)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人(仅提示,不阻断)`, + ); + } + } + private async findOrThrow(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id }, include: USER_INCLUDE }); if (user === null) { diff --git a/apps/web/src/api/endpoints.ts b/apps/web/src/api/endpoints.ts index 0d88e13..0d5ca95 100644 --- a/apps/web/src/api/endpoints.ts +++ b/apps/web/src/api/endpoints.ts @@ -20,4 +20,6 @@ export const ENDPOINTS = { `${USERS}/${encodeURIComponent(userId)}/transfer-customers`, departments: '/departments/tree', roles: '/roles', + role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`, + permissions: '/permissions', } as const; diff --git a/apps/web/src/api/generated/openapi.d.ts b/apps/web/src/api/generated/openapi.d.ts index 3b67b65..eb435bc 100644 --- a/apps/web/src/api/generated/openapi.d.ts +++ b/apps/web/src/api/generated/openapi.d.ts @@ -85,7 +85,7 @@ export interface paths { path?: never; cookie?: never; }; - /** 当前用户、角色、权限点、数据范围 */ + /** 当前用户、角色、权限点、数据范围(多角色并集) */ get: operations['MeController_current']; put?: never; post?: never; @@ -258,9 +258,61 @@ export interface paths { path?: never; cookie?: never; }; - /** 角色列表(绑定用) */ + /** + * 角色列表(游标分页 + 筛选 + 排序) + * @description 默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围 + */ get: operations['RolesController_list']; put?: never; + /** + * 新建角色 + * @description 权限点默认全空、code 自动生成;保存即做 SoD 校验(40304);特殊权限单独授予 + */ + post: operations['RolesController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/roles/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 角色详情(权限点、特殊权限、数据范围、绑定用户) */ + get: operations['RolesController_detail']; + put?: never; + post?: never; + /** + * 删除角色 + * @description 系统角色拒绝;有绑定用户(40903)只能停用 + */ + delete: operations['RolesController_remove']; + options?: never; + head?: never; + /** + * 更新角色 / 启停 + * @description 系统角色仅显示名可改;status=enabled/disabled;返回 affectedUserCount 与软提示 + */ + patch: operations['RolesController_update']; + trace?: never; + }; + '/api/v1/permissions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 权限点目录(10 模块树 + 特殊权限 + SoD 对) + * @description 模块顺序与 design/a2-ui-v1/brief.md 对齐;特殊权限默认关闭、单独授予 + */ + get: operations['PermissionsController_getTree']; + put?: never; post?: never; delete?: never; options?: never; @@ -671,6 +723,91 @@ export interface operations { }; }; }; + RolesController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RolesController_detail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RolesController_remove: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RolesController_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + PermissionsController_getTree: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; HealthController_check: { parameters: { query?: never; diff --git a/apps/web/src/api/identity.spec.ts b/apps/web/src/api/identity.spec.ts index 47a5063..2a5a05a 100644 --- a/apps/web/src/api/identity.spec.ts +++ b/apps/web/src/api/identity.spec.ts @@ -42,7 +42,7 @@ const me = { user: userSummary, roles: userSummary.roles, permissions: ['user:view'], - dataScope: { type: 'all' }, + dataScopes: [{ type: 'all' }], mustChangePassword: false, }; diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts index c468f30..33a4204 100644 --- a/apps/web/src/api/query-keys.ts +++ b/apps/web/src/api/query-keys.ts @@ -1,3 +1,4 @@ +import type { RoleListQueryInput } from './roles'; import type { UserListQueryInput } from './users'; /** TanStack Query 缓存键(唯一出处,避免散落字符串) */ @@ -9,4 +10,7 @@ export const queryKeys = { userCustomers: (userId: string) => ['users', 'customers', userId] as const, departments: ['departments'] as const, roles: ['roles'] as const, + roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const, + role: (roleId: string) => ['roles', 'detail', roleId] as const, + permissions: ['permissions'] as const, }; diff --git a/apps/web/src/api/roles.spec.ts b/apps/web/src/api/roles.spec.ts new file mode 100644 index 0000000..788d63f --- /dev/null +++ b/apps/web/src/api/roles.spec.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from '@/stores/auth-store'; + +import { + createRole, + deleteRole, + fetchPermissionTree, + fetchRoleDetail, + fetchRoleList, + toRoleListSearchParams, + updateRole, +} from './roles'; + +const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1'; +const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2'; + +const roleListItem = { + id: ROLE_ID, + code: 'reviewer', + name: '审核员', + description: '脚本与素材审核', + isSystem: false, + status: 'active', + permissionCount: 4, + specialPermissionCount: 0, + userCount: 3, + dataScope: { type: 'department_and_children' }, + createdAt: '2026-10-27T02:40:00.000Z', + updatedAt: '2026-10-27T02:40:00.000Z', +}; + +const roleDetail = { + ...roleListItem, + permissions: ['script:read', 'script:review', 'material:read', 'material:review'], + specialPermissions: [], + boundUsers: [ + { + id: USER_ID, + name: '赵一诺', + account: 'zhao.yinuo', + phoneMasked: '136****3188', + departmentName: '内容生产中心 / 素材二组', + status: 'active', + }, + ], +}; + +const permissionTree = { + modules: [ + { + module: 'script', + moduleLabel: '脚本', + sortOrder: 10, + permissions: [ + { code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false }, + { code: 'script:review', name: '审核脚本', description: '入库审核', isSpecial: false }, + ], + }, + { + module: 'delivery', + moduleLabel: '导出交付', + sortOrder: 40, + permissions: [ + { code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false }, + { code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true }, + ], + }, + ], + specialPermissions: [ + { code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true }, + ], + sodPairs: [ + { + id: 'script-write-review', + label: '脚本生成 × 脚本审核', + description: '同一角色不得同时拥有', + permissions: ['script:write', 'script:review'], + }, + ], + totalPermissions: 4, +}; + +const saveResult = { + role: roleDetail, + affectedUserCount: 3, + warnings: [], +}; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +function toUrl(input: string | URL | Request | undefined): string { + if (typeof input === 'string') { + return input; + } + if (input instanceof URL) { + return input.href; + } + return input?.url ?? ''; +} + +function bodyOf(init: RequestInit | undefined): unknown { + return typeof init?.body === 'string' ? JSON.parse(init.body) : null; +} + +describe('角色列表请求参数', () => { + it('按后端 query 约定拼装 snake_case 参数,空关键词不传', () => { + const params = toRoleListSearchParams({ + limit: 100, + status: 'all', + sort: 'created_at', + order: 'desc', + keyword: ' 审核 ', + }); + expect(params.toString()).toBe( + 'limit=100&keyword=%E5%AE%A1%E6%A0%B8&status=all&sort=created_at&order=desc', + ); + const empty = toRoleListSearchParams({ limit: 20, status: 'all', keyword: '' }); + expect(empty.has('keyword')).toBe(false); + expect(empty.has('cursor')).toBe(false); + }); + + it('带游标时完整输出', () => { + const params = toRoleListSearchParams({ limit: 20, status: 'disabled', cursor: 'cursor-2' }); + expect(params.get('cursor')).toBe('cursor-2'); + expect(params.get('status')).toBe('disabled'); + }); +}); + +describe('角色 API 客户端', () => { + beforeEach(() => { + window.localStorage.clear(); + useAuthStore.getState().setSession({ + accessToken: 'token', + user: { id: ROLE_ID, name: '灵犀管理员', account: 'admin' }, + permissions: ['role:view'], + }); + vi.restoreAllMocks(); + }); + + it('解析角色列表信封与详情', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ items: [roleListItem], next_cursor: 'cursor-2' })) + .mockResolvedValueOnce(jsonResponse(roleDetail)); + + const list = await fetchRoleList({ limit: 100, status: 'all' }); + expect(list.items).toHaveLength(1); + expect(list.next_cursor).toBe('cursor-2'); + expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/roles?limit=100&status=all'); + + const detail = await fetchRoleDetail(ROLE_ID); + expect(detail.permissions).toContain('script:review'); + expect(detail.boundUsers[0]?.name).toBe('赵一诺'); + expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/roles/${ROLE_ID}`); + }); + + it('解析权限点目录', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(permissionTree)); + const tree = await fetchPermissionTree(); + expect(tree.totalPermissions).toBe(4); + expect(tree.specialPermissions[0]?.code).toBe('delivery:export'); + }); + + it('新建角色以 camelCase body 提交并解析保存结果', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult)); + const result = await createRole({ + name: '审核员', + description: '脚本与素材审核', + permissions: ['script:read', 'script:review'], + dataScope: { type: 'department_and_children' }, + }); + expect(result.affectedUserCount).toBe(3); + const init = fetchMock.mock.calls[0]?.[1]; + expect(init?.method).toBe('POST'); + expect(bodyOf(init)).toEqual({ + name: '审核员', + description: '脚本与素材审核', + permissions: ['script:read', 'script:review'], + dataScope: { type: 'department_and_children' }, + }); + }); + + it('更新角色状态与停用原因', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult)); + await updateRole(ROLE_ID, { status: 'disabled', reason: '岗位调整' }); + const init = fetchMock.mock.calls[0]?.[1]; + expect(init?.method).toBe('PATCH'); + expect(bodyOf(init)).toEqual({ status: 'disabled', reason: '岗位调整' }); + }); + + it('删除角色返回 204', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 204 })); + await expect(deleteRole(ROLE_ID)).resolves.toBeUndefined(); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('DELETE'); + }); + + it('响应用共享 schema 校验:非法权限点编码直接报错', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ ...roleDetail, permissions: ['ghost:permission'] }), + ); + await expect(fetchRoleDetail(ROLE_ID)).rejects.toThrow(); + }); +}); diff --git a/apps/web/src/api/roles.ts b/apps/web/src/api/roles.ts new file mode 100644 index 0000000..7d89933 --- /dev/null +++ b/apps/web/src/api/roles.ts @@ -0,0 +1,82 @@ +import { + type CreateRoleInput, + createRoleInputSchema, + type PermissionTree, + permissionTreeSchema, + type RoleDetail, + roleDetailSchema, + type RoleListQuery, + type RoleListResponse, + roleListResponseSchema, + type RoleSaveResult, + roleSaveResultSchema, + type UpdateRoleInput, + updateRoleInputSchema, +} from '@lingxi/shared'; + +import { request } from './client'; +import { ENDPOINTS } from './endpoints'; + +/** 角色管理列表查询(query 参数 snake_case;角色管理页始终传全量状态) */ +export interface RoleListQueryInput extends Partial { + limit: number; + cursor?: string; + /** 排除指定角色 id(模板 / 复制来源场景由前端过滤,接口不感知) */ + excludeRoleId?: string; +} + +/** 组装 GET /roles 的 query 参数;空值不落 query,避免后端严格校验报错 */ +export function toRoleListSearchParams(query: RoleListQueryInput): URLSearchParams { + const params = new URLSearchParams(); + params.set('limit', String(query.limit)); + if (query.cursor !== undefined && query.cursor !== '') { + params.set('cursor', query.cursor); + } + if (query.keyword !== undefined && query.keyword.trim() !== '') { + params.set('keyword', query.keyword.trim()); + } + if (query.status !== undefined) { + params.set('status', query.status); + } + if (query.sort !== undefined) { + params.set('sort', query.sort); + } + if (query.order !== undefined) { + params.set('order', query.order); + } + return params; +} + +export async function fetchRoleList(query: RoleListQueryInput): Promise { + const search = toRoleListSearchParams(query).toString(); + const path = search === '' ? ENDPOINTS.roles : `${ENDPOINTS.roles}?${search}`; + const payload: unknown = await request(path); + return roleListResponseSchema.parse(payload); +} + +export async function fetchRoleDetail(roleId: string): Promise { + const payload: unknown = await request(ENDPOINTS.role(roleId)); + return roleDetailSchema.parse(payload); +} + +/** GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD(与后端同源) */ +export async function fetchPermissionTree(): Promise { + const payload: unknown = await request(ENDPOINTS.permissions); + return permissionTreeSchema.parse(payload); +} + +export async function createRole(input: CreateRoleInput): Promise { + const body = createRoleInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.roles, { method: 'POST', body }); + return roleSaveResultSchema.parse(payload); +} + +export async function updateRole(roleId: string, input: UpdateRoleInput): Promise { + const body = updateRoleInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.role(roleId), { method: 'PATCH', body }); + return roleSaveResultSchema.parse(payload); +} + +export async function deleteRole(roleId: string): Promise { + await request(ENDPOINTS.role(roleId), { method: 'DELETE' }); +} diff --git a/apps/web/src/hooks/queries/use-roles.ts b/apps/web/src/hooks/queries/use-roles.ts new file mode 100644 index 0000000..3788c82 --- /dev/null +++ b/apps/web/src/hooks/queries/use-roles.ts @@ -0,0 +1,98 @@ +import type { + CreateRoleInput, + PermissionTree, + RoleDetail, + RoleListResponse, + RoleSaveResult, + UpdateRoleInput, +} from '@lingxi/shared'; +import { + keepPreviousData, + useMutation, + type UseMutationResult, + useQuery, + useQueryClient, + type UseQueryResult, +} from '@tanstack/react-query'; + +import { queryKeys } from '@/api/query-keys'; +import { + createRole, + deleteRole, + fetchPermissionTree, + fetchRoleDetail, + fetchRoleList, + type RoleListQueryInput, + updateRole, +} from '@/api/roles'; + +export function useRoleListQuery(query: RoleListQueryInput): UseQueryResult { + return useQuery({ + queryKey: queryKeys.roleList(query), + queryFn: () => fetchRoleList(query), + placeholderData: keepPreviousData, + staleTime: 10_000, + }); +} + +export function useRoleDetailQuery(roleId: string | null): UseQueryResult { + return useQuery({ + queryKey: queryKeys.role(roleId ?? ''), + queryFn: () => fetchRoleDetail(roleId ?? ''), + enabled: roleId !== null && roleId !== '', + }); +} + +/** 权限点目录(10 模块 / 30 点 / 6 特殊 / 3 对 SoD):登录后长期复用 */ +export function usePermissionTreeQuery(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.permissions, + queryFn: fetchPermissionTree, + staleTime: 30 * 60_000, + }); +} + +function useInvalidateRoles(): () => Promise { + const queryClient = useQueryClient(); + return async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.roles }); + }; +} + +export function useCreateRoleMutation(): UseMutationResult { + const invalidate = useInvalidateRoles(); + return useMutation({ + mutationFn: (input: CreateRoleInput) => createRole(input), + onSuccess: async (result) => { + await invalidate(); + return result; + }, + }); +} + +export function useUpdateRoleMutation(): UseMutationResult< + RoleSaveResult, + Error, + { roleId: string; input: UpdateRoleInput } +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ roleId, input }: { roleId: string; input: UpdateRoleInput }) => + updateRole(roleId, input), + onSuccess: (result) => { + queryClient.setQueryData(queryKeys.role(result.role.id), result.role); + void queryClient.invalidateQueries({ queryKey: queryKeys.roles }); + }, + }); +} + +export function useDeleteRoleMutation(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (roleId: string) => deleteRole(roleId), + onSuccess: (_result, roleId) => { + queryClient.removeQueries({ queryKey: queryKeys.role(roleId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.roles }); + }, + }); +} diff --git a/apps/web/src/layout/PageBreadcrumbs.tsx b/apps/web/src/layout/PageBreadcrumbs.tsx index abc6111..0fa34a7 100644 --- a/apps/web/src/layout/PageBreadcrumbs.tsx +++ b/apps/web/src/layout/PageBreadcrumbs.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { Link } from 'react-router'; -import { getUserSectionTrail } from '@/navigation/breadcrumbs'; +import { getRoleSectionTrail, getUserSectionTrail } from '@/navigation/breadcrumbs'; import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config'; export interface PageBreadcrumbsProps { @@ -9,7 +9,10 @@ export interface PageBreadcrumbsProps { } export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode { - const entries = getUserSectionTrail(pathname) ?? getBreadcrumbs(NAV_MODULES, pathname); + const entries = + getUserSectionTrail(pathname) ?? + getRoleSectionTrail(pathname) ?? + getBreadcrumbs(NAV_MODULES, pathname); return (