feat(rbac): 批量删除 + 数据权限自定义闭环 + 若干修复

- users/orgs/resources 新增 POST /batch-delete(事务 + 失败汇总 + 用户踢下线)
- 角色数据权限自定义:GET/PUT /roles/:roleCode/orgs(roleOrg 写入路径)
- assignResources 校验 resourceCodes 存在性;角色 :id 语义改 :roleCode
- createTenant 补 createdBy;登录锁定双层阈值(IP+用户名 / 用户名全局)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 17:42:05 +08:00
parent 200cedeb59
commit 127707ffde
12 changed files with 164 additions and 17 deletions

View File

@ -17,6 +17,7 @@ const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
const LOGIN_FAIL_PREFIX = 'loginFail:';
const MAX_LOGIN_FAILS = 5;
const MAX_GLOBAL_LOGIN_FAILS = 20;
const LOGIN_LOCK_SECONDS = 900;
export interface PermissionSet {
@ -125,28 +126,35 @@ export class AuthService {
return { needSelectTenant: true, tenants: matched };
}
/** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期(按 IP + 用户名,防恶意锁他人账号)。 */
/** 登录失败锁定:双层阈值(IP+用户名 低阈值防单点爆破,用户名全局 高阈值防换 IP 爆破)。 */
private async assertNotLocked(ip: string | undefined, username: string): Promise<void> {
const count = (await this.redis.getJson<number>(this.lockKey(ip, username))) ?? 0;
if (count >= MAX_LOGIN_FAILS) {
const perIp = (await this.redis.getJson<number>(this.lockKey(ip, username))) ?? 0;
const global = (await this.redis.getJson<number>(this.globalLockKey(username))) ?? 0;
if (perIp >= MAX_LOGIN_FAILS || global >= MAX_GLOBAL_LOGIN_FAILS) {
throw new UnauthorizedException('登录失败次数过多,请稍后再试');
}
}
private async recordLoginFail(ip: string | undefined, username: string): Promise<void> {
const key = this.lockKey(ip, username);
const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1;
await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS);
const perIpKey = this.lockKey(ip, username);
const globalKey = this.globalLockKey(username);
await this.redis.setJson(perIpKey, ((await this.redis.getJson<number>(perIpKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS);
await this.redis.setJson(globalKey, ((await this.redis.getJson<number>(globalKey)) ?? 0) + 1, LOGIN_LOCK_SECONDS);
}
private async clearLoginFail(ip: string | undefined, username: string): Promise<void> {
await this.redis.del(this.lockKey(ip, username));
await this.redis.del(this.globalLockKey(username));
}
private lockKey(ip: string | undefined, username: string): string {
return `${LOGIN_FAIL_PREFIX}${ip ?? 'unknown'}:${username}`;
}
private globalLockKey(username: string): string {
return `${LOGIN_FAIL_PREFIX}global:${username}`;
}
/** 指定租户登录(单租户逻辑)。 */
private async loginTenant(
tenantCode: string,

View File

@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class PageQuery {
@ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 })
@ -24,3 +24,11 @@ export class IdParam {
@IsOptional()
id!: string;
}
export class BatchDeleteDto {
@ApiProperty({ description: '待删除记录 ID 列表', type: [String] })
@IsArray()
@IsString({ each: true })
@IsNotEmpty()
ids!: string[];
}

View File

@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/commo
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateOrgDto, UpdateOrgDto } from './dto.js';
import { BatchDeleteDto } from '../common/page.dto.js';
import { OrgsService } from './orgs.service.js';
@Controller('orgs')
@ -40,4 +41,11 @@ export class OrgsController {
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}
@Post('batch-delete')
@RequirePermissions(Permissions.org.delete)
@AuditLog('org', 'batch-delete')
batchDelete(@Body() dto: BatchDeleteDto) {
return this.service.batchDelete(dto.ids);
}
}

View File

@ -33,6 +33,19 @@ export class OrgsService extends BaseCrudService {
return this.update(id, { ...dto });
}
/** 批量删除组织(软删 + 回收 code),收集失败项。 */
async batchDelete(ids: string[]) {
const failed: string[] = [];
for (const id of ids) {
try {
await this.softRemove(id);
} catch {
failed.push(id);
}
}
return { total: ids.length, success: ids.length - failed.length, failed };
}
/** 校验父组织存在,且不会构成环(父不能是自身或自身后代)。 */
private async assertValidParent(parentCode: string | null | undefined, selfCode?: string): Promise<void> {
if (!parentCode) return; // 根节点

View File

@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/commo
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateResourceDto, UpdateResourceDto } from './dto.js';
import { BatchDeleteDto } from '../common/page.dto.js';
import { ResourcesService } from './resources.service.js';
@Controller('resources')
@ -40,4 +41,11 @@ export class ResourcesController {
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}
@Post('batch-delete')
@RequirePermissions(Permissions.resource.delete)
@AuditLog('resource', 'batch-delete')
batchDelete(@Body() dto: BatchDeleteDto) {
return this.service.batchDelete(dto.ids);
}
}

View File

@ -32,6 +32,20 @@ export class ResourcesService extends BaseCrudService {
return removed;
}
/** 批量删除:逐个软删(回收 code),最后统一清权限缓存。 */
async batchDelete(ids: string[]) {
const failed: string[] = [];
for (const id of ids) {
try {
await super.softRemove(id);
} catch {
failed.push(id);
}
}
await this.clearPermCache();
return { total: ids.length, success: ids.length - failed.length, failed };
}
/** 资源变更(含停用/删除)影响权限码聚合,清该租户全部权限缓存。 */
private async clearPermCache(): Promise<void> {
const ctx = getRequestContext();

View File

@ -54,6 +54,13 @@ export class AssignResourcesDto {
resourceCodes!: string[];
}
export class AssignOrgsDto {
@ApiProperty({ description: '部门 code 列表(数据权限=自定义时)', type: [String] })
@IsArray()
@IsString({ each: true })
orgCodes!: string[];
}
export class ListRoleQuery extends PageQuery {
@ApiPropertyOptional({ description: '角色名称(模糊)' })
@IsOptional()

View File

@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js';
import { AssignOrgsDto, AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js';
import { RolesService } from './roles.service.js';
@Controller('roles')
@ -41,16 +41,29 @@ export class RolesController {
return this.service.removeRole(id);
}
@Get(':id/resources')
@Get(':roleCode/resources')
@RequirePermissions(Permissions.role.list)
resources(@Param('id') id: string) {
return this.service.assignedResources(id);
resources(@Param('roleCode') roleCode: string) {
return this.service.assignedResources(roleCode);
}
@Put(':id/resources')
@Put(':roleCode/resources')
@RequirePermissions(Permissions.role.assignResource)
@AuditLog('role', 'assign-resource')
assign(@Param('id') id: string, @Body() dto: AssignResourcesDto) {
return this.service.assignResources(id, dto);
assign(@Param('roleCode') roleCode: string, @Body() dto: AssignResourcesDto) {
return this.service.assignResources(roleCode, dto);
}
@Get(':roleCode/orgs')
@RequirePermissions(Permissions.role.list)
orgs(@Param('roleCode') roleCode: string) {
return this.service.assignedOrgs(roleCode);
}
@Put(':roleCode/orgs')
@RequirePermissions(Permissions.role.update)
@AuditLog('role', 'assign-org')
assignOrgs(@Param('roleCode') roleCode: string, @Body() dto: AssignOrgsDto) {
return this.service.assignOrgs(roleCode, dto);
}
}

View File

@ -4,7 +4,7 @@ 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';
import { AssignOrgsDto, AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js';
@Injectable()
export class RolesService extends BaseCrudService {
@ -67,6 +67,33 @@ export class RolesService extends BaseCrudService {
return rows.map((r) => r.resourceCode);
}
/** 角色已分配的数据权限部门 code 列表(data_scope=custom)。 */
async assignedOrgs(roleCode: string) {
const ctx = getRequestContext()!;
const rows = await this.prisma.client.roleOrg.findMany({
where: { tenantCode: ctx.tenantCode, roleCode },
select: { orgCode: true },
});
return rows.map((r) => r.orgCode);
}
/** 分配数据权限部门(事务化替换)。 */
async assignOrgs(roleCode: string, dto: AssignOrgsDto) {
const ctx = getRequestContext()!;
const role = await this.db().findFirst({ where: { code: roleCode } });
if (!role) throw new BadRequestException('角色不存在');
await this.prisma.raw.$transaction(async (tx) => {
await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
if (dto.orgCodes.length > 0) {
await tx.roleOrg.createMany({
data: dto.orgCodes.map((orgCode) => ({ tenantCode: ctx.tenantCode, roleCode, orgCode })),
});
}
});
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
return null;
}
async assignResources(roleCode: string, dto: AssignResourcesDto) {
const ctx = getRequestContext()!;
// 平台级权限(tenant:*)仅平台租户可授予,非平台租户一律剥离
@ -76,6 +103,18 @@ export class RolesService extends BaseCrudService {
: dto.resourceCodes.filter((code) => !code.startsWith(PLATFORM_PERMISSION_PREFIX));
const role = await this.db().findFirst({ where: { code: roleCode } });
if (!role) throw new BadRequestException('角色不存在');
// 校验 resourceCodes 存在性(避免静默忽略错误码)
const existing = resourceCodes.length
? await this.prisma.client.resource.findMany({
where: { tenantCode: ctx.tenantCode, code: { in: resourceCodes }, deletedAt: null },
select: { code: true },
})
: [];
const validCodes = existing.map((r) => r.code);
if (validCodes.length !== resourceCodes.length) {
const invalid = resourceCodes.filter((c) => !validCodes.includes(c));
throw new BadRequestException(`资源不存在:${invalid.join(', ')}`);
}
// 事务化:删除 + 重建授权,避免中途失败导致角色授权被清空
await this.prisma.client.$transaction(async (tx) => {
await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });

View File

@ -4,6 +4,7 @@ import type { PrismaClient } from '@prisma/client';
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 { TENANT_ADMIN_ROLE_CODE } from '../common/constants.js';
import { initTenant } from '../common/tenant-template.js';
import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js';
@ -19,11 +20,18 @@ export class TenantsService extends BaseCrudService {
/** 创建租户并初始化(事务化);未提供管理员密码时生成随机密码返回。 */
async createTenant(dto: CreateTenantDto) {
const ctx = getRequestContext();
const code = dto.code;
const adminPassword = dto.adminPassword ?? randomUUID().slice(0, 12);
await this.prisma.raw.$transaction(async (tx) => {
await tx.tenant.create({
data: { code, name: dto.name, status: dto.status ?? 'enabled', remark: dto.remark },
data: {
code,
name: dto.name,
status: dto.status ?? 'enabled',
remark: dto.remark,
createdBy: ctx?.userId ?? null,
},
});
await initTenant({
prisma: tx as unknown as PrismaClient,

View File

@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { BatchDeleteDto } from '../common/page.dto.js';
import { UsersService } from './users.service.js';
@Controller('users')
@ -60,4 +61,11 @@ export class UsersController {
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}
@Post('batch-delete')
@RequirePermissions(Permissions.user.delete)
@AuditLog('user', 'batch-delete')
batchDelete(@Body() dto: BatchDeleteDto) {
return this.service.batchDelete(dto.ids);
}
}

View File

@ -81,6 +81,19 @@ export class UsersService extends BaseCrudService {
return this.omitPasswordHash(removed);
}
/** 批量删除:逐个软删 + 吊销会话,收集失败项。 */
async batchDelete(ids: string[]) {
const failed: string[] = [];
for (const id of ids) {
try {
await this.softRemove(id);
} catch {
failed.push(id);
}
}
return { total: ids.length, success: ids.length - failed.length, failed };
}
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
private async invalidateSessions(userId: string): Promise<void> {
const ctx = getRequestContext();