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:
parent
200cedeb59
commit
127707ffde
@ -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,
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; // 根节点
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 } });
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user