fix(security): P0 安全越权堵漏(C1/H1-H6/H10/H11/H13/M1/M4/M6/M8-M11/M22)

- 平台管理员模型:super_admin=平台超管、tenant_super_admin=租户顶层(保留码),tenant:* 仅平台可授予
- 软删改名回收唯一键 + P2002→409
- 删除角色级联清理关联 + 聚合过滤已删/停用角色资源
- tokenVersion 会话吊销(重置密码/停用/删除即时踢下线)
- data_scope self 按 userId 过滤 + 详情数据权限校验
- 组织树防环、seed 不重置密码、日志脱敏补 phone/email
- JWT 密钥强校验、pageSize 上限、DTO 枚举校验、Prisma 错误映射、授权事务化

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 16:10:00 +08:00
parent 73ed067a82
commit 513688a5e6
22 changed files with 290 additions and 65 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `sys_user` ADD COLUMN `token_version` INT NOT NULL DEFAULT 0;

View File

@ -35,6 +35,7 @@ model User {
phone String? @db.VarChar(32)
avatar String? @db.VarChar(255)
status String @default("enabled") @db.VarChar(16)
tokenVersion Int @default(0) @map("token_version")
orgCode String? @map("org_code") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)

View File

@ -1,11 +1,12 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { Permissions } from '../src/common/permissions.js';
import { PLATFORM_ROLE_CODE, PLATFORM_TENANT_CODE } from '../src/common/constants.js';
const prisma = new PrismaClient();
const TENANT = 'touyun';
const SUPER_ROLE = 'super_admin';
const TENANT = PLATFORM_TENANT_CODE;
const SUPER_ROLE = PLATFORM_ROLE_CODE;
const ADMIN_USERNAME = 'touyun';
const ADMIN_PASSWORD = 'Touyun@123';
@ -141,11 +142,11 @@ async function main(): Promise<void> {
create: { tenantCode: TENANT, code: SUPER_ROLE, name: '超级管理员', dataScope: 'all', status: 'enabled' },
});
// 4. 管理员用户
// 4. 管理员用户(update 分支不重置密码/状态,避免重跑 seed 重置管理员密码或重新启用账号)
const passwordHash = await bcrypt.hash(ADMIN_PASSWORD, 10);
const user = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode: TENANT, username: ADMIN_USERNAME } },
update: { passwordHash, nickname: '管理员', status: 'enabled' },
update: { nickname: '管理员' },
create: {
tenantCode: TENANT,
username: ADMIN_USERNAME,

View File

@ -5,11 +5,19 @@ import { AuthService } from './auth.service.js';
import { AuditLogsModule } from '../audit-logs/audit-logs.module.js';
import { LoginLogsModule } from '../login-logs/login-logs.module.js';
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error('JWT_SECRET 环境变量未配置,拒绝启动');
}
if (process.env.NODE_ENV === 'production' && (jwtSecret === 'dev-secret-change-me' || jwtSecret.length < 32)) {
throw new Error('生产环境 JWT_SECRET 过弱,请配置至少 32 位强密钥');
}
@Module({
imports: [
JwtModule.register({
global: true,
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
secret: jwtSecret,
signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) },
}),
AuditLogsModule,

View File

@ -10,10 +10,10 @@ import type { DataScope } from '../common/enums.js';
import type { RequestContext } from '../common/context/request-context.js';
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
import { LoginLogsService } from '../login-logs/login-logs.service.js';
import { PLATFORM_ROLE_CODE as SUPER_ROLE, permCacheKey, userStateCacheKey } from '../common/constants.js';
const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
const SUPER_ROLE = 'super_admin';
export interface PermissionSet {
super: boolean;
@ -115,7 +115,7 @@ export class AuthService {
ip: ip ?? null,
userAgent: userAgent ?? null,
});
return this.issue(dto.tenantCode, user.id, user.username);
return this.issue(dto.tenantCode, user.id, user.username, user.tokenVersion);
}
async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
@ -134,7 +134,7 @@ export class AuthService {
data: { revokedAt: new Date() },
});
await this.writePermissionCache(record.tenantCode, user.id);
return this.issue(record.tenantCode, user.id, user.username);
return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion);
}
async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise<null> {
@ -251,7 +251,23 @@ export class AuthService {
}
private permKey(tenantCode: string, userId: string): string {
return `perm:${tenantCode}:${userId}`;
return permCacheKey(tenantCode, userId);
}
/** 校验用户当前是否可用(status=enabled 且 tokenVersion 与签发时一致)。 */
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
const key = userStateCacheKey(tenantCode, userId);
let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key);
if (!state) {
const user = await this.prisma.client.user.findFirst({
where: { tenantCode, id: userId, deletedAt: null },
select: { status: true, tokenVersion: true },
});
if (!user) return false;
state = { status: user.status, tokenVersion: user.tokenVersion };
await this.redis.setJson(key, state, ACCESS_TTL);
}
return state.status === 'enabled' && state.tokenVersion === tokenVersion;
}
/** 聚合权限码与超管标志。 */
@ -261,16 +277,26 @@ export class AuthService {
select: { roleCode: true },
});
const roleCodes = roleRows.map((r) => r.roleCode);
const isSuper = roleCodes.includes(SUPER_ROLE);
const resourceRows = await this.prisma.client.roleResource.findMany({
where: { tenantCode, roleCode: { in: roleCodes } },
select: { resourceCode: true },
});
// 过滤已删除角色(软删角色不参与权限聚合)
const activeRoles = roleCodes.length
? await this.prisma.client.role.findMany({
where: { tenantCode, code: { in: roleCodes }, deletedAt: null },
select: { code: true },
})
: [];
const activeRoleCodes = activeRoles.map((r) => r.code);
const isSuper = activeRoleCodes.includes(SUPER_ROLE);
const resourceRows = activeRoleCodes.length
? await this.prisma.client.roleResource.findMany({
where: { tenantCode, roleCode: { in: activeRoleCodes } },
select: { resourceCode: true },
})
: [];
const resourceCodes = [...new Set(resourceRows.map((r) => r.resourceCode))];
const permissions: string[] = resourceCodes.length
? (
await this.prisma.client.resource.findMany({
where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, deletedAt: null },
where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, status: 'enabled', deletedAt: null },
select: { code: true },
})
).map((r) => r.code)
@ -320,8 +346,9 @@ export class AuthService {
tenantCode: string,
userId: string,
username: string,
tokenVersion: number,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username });
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion });
const jti = randomUUID();
await this.prisma.client.refreshToken.create({
data: {

View File

@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { IS_PUBLIC_KEY } from './decorators.js';
import { AuthService } from './auth.service.js';
export interface AuthUser {
userId: string;
@ -14,6 +15,7 @@ export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly reflector: Reflector,
private readonly auth: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@ -30,10 +32,13 @@ export class JwtAuthGuard implements CanActivate {
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('未登录');
try {
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string }>(token);
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number }>(token);
const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
return true;
} catch {
} catch (e) {
if (e instanceof UnauthorizedException) throw e;
throw new UnauthorizedException('token 无效或已过期');
}
}

View File

@ -1,6 +1,21 @@
import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from './context/request-context.js';
/**
* 软删时需「改名回收」唯一键的模型映射:modelName → 唯一字段及其列长。
* 目的:软删只置 deletedAt 不释放 @@unique,重建同名会 P2002;删除时把唯一字段改名
* 成「原值(截断) + #id」释放唯一键,展示名字段(name/label)保持不变。
*/
const RECYCLE_FIELDS: Record<string, { field: string; maxLen: number }> = {
user: { field: 'username', maxLen: 64 },
role: { field: 'code', maxLen: 64 },
resource: { field: 'code', maxLen: 128 },
org: { field: 'code', maxLen: 64 },
dictType: { field: 'code', maxLen: 64 },
dictItem: { field: 'value', maxLen: 64 },
config: { field: 'key', maxLen: 128 },
};
/**
* 通用 CRUD 基类:封装分页/创建/更新/软删。
* 创建时自动注入 tenantCode(Tenant 模型除外);软删由子类按需调用 softRemove。
@ -47,6 +62,18 @@ export class BaseCrudService {
}
async softRemove(id: string) {
return this.db().update({ where: { id }, data: { deletedAt: new Date() } });
const recycle = RECYCLE_FIELDS[this.modelName];
if (!recycle) {
return this.db().update({ where: { id }, data: { deletedAt: new Date() } });
}
// 先读原值,改名回收唯一键,再软删
const record = await this.findById(id);
const original = record?.[recycle.field];
if (original == null) {
return this.db().update({ where: { id }, data: { deletedAt: new Date() } });
}
const suffix = `#${id}`;
const recycled = `${String(original).slice(0, recycle.maxLen - suffix.length)}${suffix}`;
return this.db().update({ where: { id }, data: { [recycle.field]: recycled, deletedAt: new Date() } });
}
}

24
src/common/constants.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* 平台级常量:平台租户与保留角色码的单一事实源。
*/
/** 平台租户(投云科技,根租户):唯一可维护租户表的租户。 */
export const PLATFORM_TENANT_CODE = 'touyun';
/** 平台超管角色码(保留):isSuper 全绕过 + 唯一拥有 tenant:* 权限。 */
export const PLATFORM_ROLE_CODE = 'super_admin';
/** 业务租户顶层管理员角色码(保留):本租户内除 tenant:* 外全权限。 */
export const TENANT_ADMIN_ROLE_CODE = 'tenant_super_admin';
/** 保留角色码:租户不可自行创建。 */
export const RESERVED_ROLE_CODES: readonly string[] = [PLATFORM_ROLE_CODE, TENANT_ADMIN_ROLE_CODE];
/** 平台级权限码前缀(仅平台租户可授予)。 */
export const PLATFORM_PERMISSION_PREFIX = 'tenant:';
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
export const permCacheKey = (tenantCode: string, userId: string): string => `perm:${tenantCode}:${userId}`;
/** 用户状态缓存 key(Redis,status + tokenVersion,供 JwtAuthGuard 每请求校验)。 */
export const userStateCacheKey = (tenantCode: string, userId: string): string => `user:${tenantCode}:${userId}`;

View File

@ -32,6 +32,8 @@ export async function resolveVisibleOrgs(opts: {
case 'all':
return [];
case 'self':
// 仅本人:不走组织过滤,由调用方按 userId 过滤(返回哨兵避免误放大为全量)
return ['__none__'];
case 'dept':
return orgCode ? [orgCode] : ['__none__'];
case 'dept_and_child':
@ -47,14 +49,22 @@ export async function resolveVisibleOrgs(opts: {
}
}
/** 收集某部门及其全部后代部门的 code(含自身)。 */
/** 收集某部门及其全部后代部门的 code(含自身),带 visited 防环。 */
async function collectDescendants(prisma: PrismaClient, tenantCode: string, rootCode: string): Promise<string[]> {
const result: string[] = [];
const visited = new Set<string>();
let frontier = [rootCode];
while (frontier.length > 0) {
result.push(...frontier);
const next: string[] = [];
for (const code of frontier) {
if (visited.has(code)) continue;
visited.add(code);
result.push(code);
next.push(code);
}
if (next.length === 0) break;
const children = await prisma.org.findMany({
where: { tenantCode, parentCode: { in: frontier }, deletedAt: null },
where: { tenantCode, parentCode: { in: next }, deletedAt: null },
select: { code: true },
});
frontier = children.map((c) => c.code);

View File

@ -1,4 +1,5 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Response } from 'express';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@ -13,14 +14,32 @@ export class AllExceptionsFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const body = exception instanceof HttpException ? exception.getResponse() : null;
const message =
typeof body === 'string'
? body
: Array.isArray((body as { message?: unknown })?.message)
? ((body as { message: string[] }).message.join('; '))
: ((body as { message?: string })?.message ?? (exception as Error).message ?? 'Internal server error');
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string;
if (exception instanceof HttpException) {
status = exception.getStatus();
const body = exception.getResponse();
message =
typeof body === 'string'
? body
: Array.isArray((body as { message?: unknown })?.message)
? ((body as { message: string[] }).message.join('; '))
: ((body as { message?: string })?.message ?? exception.message);
} else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
if (exception.code === 'P2002') {
status = HttpStatus.CONFLICT;
message = '数据已存在(唯一约束冲突),请勿重复创建';
} else if (exception.code === 'P2025') {
status = HttpStatus.NOT_FOUND;
message = '记录不存在或已被删除';
} else {
message = '数据库操作失败';
}
} else {
// 未知异常:不向客户端泄漏内部错误细节
message = '系统内部错误';
}
if (status >= 500) {
this.logger.error({ err: exception }, message);

View File

@ -9,6 +9,9 @@ const SENSITIVE_KEYS = new Set([
'token',
'secret',
'authorization',
'phone',
'mobile',
'email',
]);
export function maskSensitive(value: unknown): unknown {

View File

@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class PageQuery {
@ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 })
@ -15,6 +15,7 @@ export class PageQuery {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize = 20;
}

View File

@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { CommonStatus } from '../common/enums.js';
/** 标签色(对应前端 TagType)。 */
const COLOR_TYPES = ['primary', 'success', 'warning', 'danger', 'info'] as const;
@ -29,7 +30,7 @@ export class UpdateDictTypeDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsString()
@IsIn(Object.values(CommonStatus))
status?: string;
@ApiPropertyOptional({ description: '备注' })
@ -83,7 +84,7 @@ export class UpdateDictItemDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsString()
@IsIn(Object.values(CommonStatus))
status?: string;
@ApiPropertyOptional({ description: '标签色:primary/success/warning/danger/info' })

View File

@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { CommonStatus } from '../common/enums.js';
export class CreateOrgDto {
@ApiProperty({ description: '组织 code(租户内唯一)' })
@ -51,6 +52,6 @@ export class UpdateOrgDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsString()
@IsIn(Object.values(CommonStatus))
status?: string;
}

View File

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { CreateOrgDto, UpdateOrgDto } from './dto.js';
@ -9,11 +9,35 @@ export class OrgsService extends BaseCrudService {
super(prisma, 'org');
}
createOrg(dto: CreateOrgDto) {
async createOrg(dto: CreateOrgDto) {
await this.assertValidParent(dto.parentCode);
return this.create({ ...dto });
}
updateOrg(id: string, dto: UpdateOrgDto) {
async updateOrg(id: string, dto: UpdateOrgDto) {
if (dto.parentCode !== undefined) {
const current = await this.findById(id);
await this.assertValidParent(dto.parentCode, current?.code);
}
return this.update(id, { ...dto });
}
/** 校验父组织存在,且不会构成环(父不能是自身或自身后代)。 */
private async assertValidParent(parentCode: string | null | undefined, selfCode?: string): Promise<void> {
if (!parentCode) return; // 根节点
const parent = await this.db().findFirst({ where: { code: parentCode } });
if (!parent) throw new BadRequestException('父组织不存在');
if (!selfCode) return;
let cur: string | null = parentCode;
const visited = new Set<string>();
while (cur && !visited.has(cur)) {
if (cur === selfCode) throw new BadRequestException('不能将组织挂到自身或其子组织下');
visited.add(cur);
const node: { parentCode: string | null } | null = await this.db().findFirst({
where: { code: cur },
select: { parentCode: true },
});
cur = node?.parentCode ?? null;
}
}
}

View File

@ -1,9 +1,10 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { CommonStatus, ResourceType } from '../common/enums.js';
export class CreateResourceDto {
@ApiProperty({ description: '类型:menu/button/api' })
@IsString()
@IsIn(Object.values(ResourceType))
@IsNotEmpty()
type!: string;
@ -96,6 +97,6 @@ export class UpdateResourceDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsString()
@IsIn(Object.values(CommonStatus))
status?: string;
}

View File

@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
import { CommonStatus, DataScope } from '../common/enums.js';
export class CreateRoleDto {
@ApiProperty({ description: '角色编码' })
@ -15,7 +16,7 @@ export class CreateRoleDto {
@ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom', default: 'all' })
@IsOptional()
@IsString()
@IsIn(Object.values(DataScope))
dataScope?: string;
@ApiPropertyOptional({ description: '备注' })
@ -32,12 +33,12 @@ export class UpdateRoleDto {
@ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom' })
@IsOptional()
@IsString()
@IsIn(Object.values(DataScope))
dataScope?: string;
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsString()
@IsIn(Object.values(CommonStatus))
status?: string;
@ApiPropertyOptional({ description: '备注' })
@ -66,6 +67,6 @@ export class ListRoleQuery extends PageQuery {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsString()
@IsIn(Object.values(CommonStatus))
status?: string;
}

View File

@ -38,7 +38,7 @@ export class RolesController {
@RequirePermissions(Permissions.role.delete)
@AuditLog('role', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
return this.service.removeRole(id);
}
@Get(':id/resources')

View File

@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { PLATFORM_PERMISSION_PREFIX, PLATFORM_TENANT_CODE, RESERVED_ROLE_CODES } from '../common/constants.js';
import { AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js';
@Injectable()
@ -15,6 +16,9 @@ export class RolesService extends BaseCrudService {
}
createRole(dto: CreateRoleDto) {
if (RESERVED_ROLE_CODES.includes(dto.code)) {
throw new BadRequestException(`角色编码 ${dto.code} 为系统保留,不可创建`);
}
return this.create({ ...dto });
}
@ -30,6 +34,23 @@ export class RolesService extends BaseCrudService {
return this.paginate(q.page, q.pageSize, where);
}
/** 删除角色:级联清理关联并清权限缓存(保留角色不可删)。 */
async removeRole(id: string) {
const ctx = getRequestContext()!;
const role = await this.findById(id);
if (!role) throw new BadRequestException('角色不存在');
if (RESERVED_ROLE_CODES.includes(role.code)) {
throw new BadRequestException(`角色 ${role.code} 为系统保留角色,不可删除`);
}
const roleCode = role.code;
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.prisma.client.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.prisma.client.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.softRemove(id);
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
return null;
}
async assignedResources(roleCode: string) {
const ctx = getRequestContext()!;
const rows = await this.prisma.client.roleResource.findMany({
@ -41,12 +62,22 @@ export class RolesService extends BaseCrudService {
async assignResources(roleCode: string, dto: AssignResourcesDto) {
const ctx = getRequestContext()!;
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
if (dto.resourceCodes.length > 0) {
await this.prisma.client.roleResource.createMany({
data: dto.resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
});
}
// 平台级权限(tenant:*)仅平台租户可授予,非平台租户一律剥离
const resourceCodes =
ctx.tenantCode === PLATFORM_TENANT_CODE
? dto.resourceCodes
: dto.resourceCodes.filter((code) => !code.startsWith(PLATFORM_PERMISSION_PREFIX));
const role = await this.db().findFirst({ where: { code: roleCode } });
if (!role) throw new BadRequestException('角色不存在');
// 事务化:删除 + 重建授权,避免中途失败导致角色授权被清空
await this.prisma.client.$transaction(async (tx) => {
await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
if (resourceCodes.length > 0) {
await tx.roleResource.createMany({
data: resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
});
}
});
// 授权变更:删除该租户全部用户权限缓存,下一次请求守卫重新聚合并回填
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
return null;

View File

@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
import { CommonStatus } from '../common/enums.js';
export class CreateTenantDto {
@ApiProperty({ description: '租户编码' })
@ -15,7 +16,7 @@ export class CreateTenantDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled', default: 'enabled' })
@IsOptional()
@IsString()
@IsIn(Object.values(CommonStatus))
status?: string;
@ApiPropertyOptional({ description: '备注' })
@ -32,7 +33,7 @@ export class UpdateTenantDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsString()
@IsIn(Object.values(CommonStatus))
status?: string;
@ApiPropertyOptional({ description: '备注' })
@ -54,6 +55,6 @@ export class ListTenantQuery extends PageQuery {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsString()
@IsIn(Object.values(CommonStatus))
status?: string;
}

View File

@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
import { UserStatus } from '../common/enums.js';
export class CreateUserDto {
@ApiProperty({ description: '用户名' })
@ -67,7 +68,7 @@ export class UpdateUserDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled/locked' })
@IsOptional()
@IsString()
@IsIn(Object.values(UserStatus))
status?: string;
}
@ -96,6 +97,6 @@ export class ListUserQuery extends PageQuery {
@ApiPropertyOptional({ description: '状态:enabled/disabled/locked' })
@IsOptional()
@IsString()
@IsIn(Object.values(UserStatus))
status?: string;
}

View File

@ -1,13 +1,18 @@
import { Injectable } from '@nestjs/common';
import { ForbiddenException, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { permCacheKey, userStateCacheKey } from '../common/constants.js';
import { CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
@Injectable()
export class UsersService extends BaseCrudService {
constructor(prisma: PrismaService) {
constructor(
prisma: PrismaService,
private readonly redis: RedisService,
) {
super(prisma, 'user');
}
@ -25,8 +30,10 @@ export class UsersService extends BaseCrudService {
if (q.nickname) where.nickname = { contains: q.nickname };
if (q.phone) where.phone = { contains: q.phone };
if (q.status) where.status = q.status;
// 数据权限:orgCodes 为空表示「全部可见」,否则限制在可见部门集合
if (ctx && ctx.orgCodes.length > 0) {
// 数据权限:仅本人按 userId 过滤;否则限制在可见部门集合(空数组=全部可见)
if (ctx && ctx.dataScope === 'self') {
where.id = ctx.userId;
} else if (ctx && ctx.orgCodes.length > 0) {
where.orgCode = { in: ctx.orgCodes };
}
const result = await this.paginate(q.page, q.pageSize, where);
@ -35,26 +42,55 @@ export class UsersService extends BaseCrudService {
}
async findById(id: string) {
await this.assertCanView(id);
const user = await super.findById(id);
return user ? this.omitPasswordHash(user) : user;
}
/** 详情查看的数据权限校验:越界直接 403。 */
private async assertCanView(userId: string): Promise<void> {
const ctx = getRequestContext();
if (!ctx || ctx.dataScope === 'all') return;
if (ctx.dataScope === 'self') {
if (userId !== ctx.userId) throw new ForbiddenException('无权查看该用户');
return;
}
const scopes = ctx.orgCodes.length ? ctx.orgCodes : ['__none__'];
const target = await this.db().findFirst({ where: { id: userId, orgCode: { in: scopes } } });
if (!target) throw new ForbiddenException('无权查看该用户');
}
async updateUser(id: string, dto: UpdateUserDto) {
const updated = await this.update(id, { ...dto });
if (dto.status && dto.status !== 'enabled') {
await this.invalidateSessions(id);
}
return this.omitPasswordHash(updated);
}
async resetPassword(id: string, dto: ResetPasswordDto) {
const passwordHash = await bcrypt.hash(dto.password, 10);
const updated = await this.update(id, { passwordHash });
await this.invalidateSessions(id);
return this.omitPasswordHash(updated);
}
async softRemove(id: string) {
const removed = await super.softRemove(id);
await this.invalidateSessions(id);
return this.omitPasswordHash(removed);
}
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
private async invalidateSessions(userId: string): Promise<void> {
const ctx = getRequestContext();
if (!ctx) return;
await this.db().updateMany({ where: { id: userId }, data: { tokenVersion: { increment: 1 } } });
await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode: ctx.tenantCode, userId } });
await this.redis.del(permCacheKey(ctx.tenantCode, userId));
await this.redis.del(userStateCacheKey(ctx.tenantCode, userId));
}
/** 响应边界剥掉 passwordHash,避免泄漏(登录内部校验直接查库,不受影响)。 */
private omitPasswordHash<T extends { passwordHash?: unknown }>(row: T): Omit<T, 'passwordHash'> {
const { passwordHash: _passwordHash, ...rest } = row;