1275 lines
32 KiB
Markdown
1275 lines
32 KiB
Markdown
# Especificacion Tecnica: RBAC Backend
|
|
|
|
## Identificacion
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **Modulo** | MGN-003 Roles/RBAC |
|
|
| **Componente** | Backend API |
|
|
| **Framework** | NestJS |
|
|
| **Version** | 1.0 |
|
|
| **Fecha** | 2025-12-05 |
|
|
|
|
---
|
|
|
|
## Arquitectura
|
|
|
|
```
|
|
src/
|
|
├── modules/
|
|
│ └── rbac/
|
|
│ ├── rbac.module.ts
|
|
│ ├── controllers/
|
|
│ │ ├── roles.controller.ts
|
|
│ │ └── permissions.controller.ts
|
|
│ ├── services/
|
|
│ │ ├── roles.service.ts
|
|
│ │ ├── permissions.service.ts
|
|
│ │ └── rbac-cache.service.ts
|
|
│ ├── guards/
|
|
│ │ ├── rbac.guard.ts
|
|
│ │ └── owner.guard.ts
|
|
│ ├── decorators/
|
|
│ │ ├── permissions.decorator.ts
|
|
│ │ ├── roles.decorator.ts
|
|
│ │ └── public.decorator.ts
|
|
│ ├── dto/
|
|
│ │ ├── create-role.dto.ts
|
|
│ │ ├── update-role.dto.ts
|
|
│ │ ├── assign-roles.dto.ts
|
|
│ │ └── ...
|
|
│ ├── entities/
|
|
│ │ ├── role.entity.ts
|
|
│ │ ├── permission.entity.ts
|
|
│ │ ├── role-permission.entity.ts
|
|
│ │ └── user-role.entity.ts
|
|
│ └── interfaces/
|
|
│ └── permission-metadata.interface.ts
|
|
```
|
|
|
|
---
|
|
|
|
## Entidades
|
|
|
|
### Role Entity
|
|
|
|
```typescript
|
|
// entities/role.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToMany,
|
|
JoinTable,
|
|
OneToMany,
|
|
ManyToOne,
|
|
CreateDateColumn,
|
|
UpdateDateColumn,
|
|
DeleteDateColumn,
|
|
} from 'typeorm';
|
|
import { Permission } from './permission.entity';
|
|
import { UserRole } from './user-role.entity';
|
|
import { User } from '../../users/entities/user.entity';
|
|
import { Tenant } from '../../tenants/entities/tenant.entity';
|
|
|
|
@Entity({ schema: 'core_rbac', name: 'roles' })
|
|
export class Role {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ name: 'tenant_id' })
|
|
tenantId: string;
|
|
|
|
@ManyToOne(() => Tenant)
|
|
tenant: Tenant;
|
|
|
|
@Column({ length: 50 })
|
|
name: string;
|
|
|
|
@Column({ length: 50 })
|
|
slug: string;
|
|
|
|
@Column({ length: 500, nullable: true })
|
|
description: string;
|
|
|
|
@Column({ name: 'is_built_in', default: false })
|
|
isBuiltIn: boolean;
|
|
|
|
@Column({ name: 'is_active', default: true })
|
|
isActive: boolean;
|
|
|
|
@ManyToMany(() => Permission)
|
|
@JoinTable({
|
|
name: 'role_permissions',
|
|
schema: 'core_rbac',
|
|
joinColumn: { name: 'role_id' },
|
|
inverseJoinColumn: { name: 'permission_id' },
|
|
})
|
|
permissions: Permission[];
|
|
|
|
@OneToMany(() => UserRole, (userRole) => userRole.role)
|
|
userRoles: UserRole[];
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at' })
|
|
updatedAt: Date;
|
|
|
|
@Column({ name: 'created_by', nullable: true })
|
|
createdBy: string;
|
|
|
|
@ManyToOne(() => User)
|
|
createdByUser: User;
|
|
|
|
@Column({ name: 'updated_by', nullable: true })
|
|
updatedBy: string;
|
|
|
|
@DeleteDateColumn({ name: 'deleted_at' })
|
|
deletedAt: Date;
|
|
|
|
@Column({ name: 'deleted_by', nullable: true })
|
|
deletedBy: string;
|
|
|
|
// Virtual: count of users
|
|
usersCount?: number;
|
|
}
|
|
```
|
|
|
|
### Permission Entity
|
|
|
|
```typescript
|
|
// entities/permission.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
CreateDateColumn,
|
|
} from 'typeorm';
|
|
|
|
@Entity({ schema: 'core_rbac', name: 'permissions' })
|
|
export class Permission {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ length: 100, unique: true })
|
|
code: string;
|
|
|
|
@Column({ length: 100 })
|
|
name: string;
|
|
|
|
@Column({ length: 500, nullable: true })
|
|
description: string;
|
|
|
|
@Column({ length: 50 })
|
|
module: string;
|
|
|
|
@Column({ name: 'parent_code', nullable: true })
|
|
parentCode: string;
|
|
|
|
@ManyToOne(() => Permission)
|
|
parent: Permission;
|
|
|
|
@Column({ name: 'is_deprecated', default: false })
|
|
isDeprecated: boolean;
|
|
|
|
@Column({ name: 'sort_order', default: 0 })
|
|
sortOrder: number;
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
}
|
|
```
|
|
|
|
### UserRole Entity
|
|
|
|
```typescript
|
|
// entities/user-role.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
CreateDateColumn,
|
|
} from 'typeorm';
|
|
import { User } from '../../users/entities/user.entity';
|
|
import { Role } from './role.entity';
|
|
|
|
@Entity({ schema: 'core_rbac', name: 'user_roles' })
|
|
export class UserRole {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ name: 'user_id' })
|
|
userId: string;
|
|
|
|
@ManyToOne(() => User)
|
|
user: User;
|
|
|
|
@Column({ name: 'role_id' })
|
|
roleId: string;
|
|
|
|
@ManyToOne(() => Role)
|
|
role: Role;
|
|
|
|
@CreateDateColumn({ name: 'assigned_at' })
|
|
assignedAt: Date;
|
|
|
|
@Column({ name: 'assigned_by', nullable: true })
|
|
assignedBy: string;
|
|
|
|
@Column({ name: 'expires_at', nullable: true })
|
|
expiresAt: Date;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## DTOs
|
|
|
|
### Create Role DTO
|
|
|
|
```typescript
|
|
// dto/create-role.dto.ts
|
|
import {
|
|
IsString,
|
|
IsOptional,
|
|
IsArray,
|
|
IsUUID,
|
|
MinLength,
|
|
MaxLength,
|
|
Matches,
|
|
ArrayMinSize,
|
|
} from 'class-validator';
|
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
|
|
export class CreateRoleDto {
|
|
@ApiProperty({
|
|
description: 'Nombre del rol',
|
|
example: 'Vendedor',
|
|
minLength: 3,
|
|
maxLength: 50,
|
|
})
|
|
@IsString()
|
|
@MinLength(3)
|
|
@MaxLength(50)
|
|
name: string;
|
|
|
|
@ApiPropertyOptional({
|
|
description: 'Descripcion del rol',
|
|
example: 'Equipo de ventas',
|
|
maxLength: 500,
|
|
})
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(500)
|
|
description?: string;
|
|
|
|
@ApiProperty({
|
|
description: 'IDs de permisos a asignar',
|
|
example: ['uuid-1', 'uuid-2'],
|
|
type: [String],
|
|
})
|
|
@IsArray()
|
|
@IsUUID('4', { each: true })
|
|
@ArrayMinSize(1)
|
|
permissionIds: string[];
|
|
}
|
|
```
|
|
|
|
### Update Role DTO
|
|
|
|
```typescript
|
|
// dto/update-role.dto.ts
|
|
import { PartialType, OmitType } from '@nestjs/swagger';
|
|
import { CreateRoleDto } from './create-role.dto';
|
|
import { IsOptional, IsBoolean } from 'class-validator';
|
|
|
|
export class UpdateRoleDto extends PartialType(CreateRoleDto) {
|
|
@IsOptional()
|
|
@IsBoolean()
|
|
isActive?: boolean;
|
|
}
|
|
```
|
|
|
|
### Assign Roles DTO
|
|
|
|
```typescript
|
|
// dto/assign-roles.dto.ts
|
|
import { IsArray, IsUUID, ArrayMinSize } from 'class-validator';
|
|
import { ApiProperty } from '@nestjs/swagger';
|
|
|
|
export class AssignRolesDto {
|
|
@ApiProperty({
|
|
description: 'IDs de roles a asignar',
|
|
example: ['role-uuid-1', 'role-uuid-2'],
|
|
type: [String],
|
|
})
|
|
@IsArray()
|
|
@IsUUID('4', { each: true })
|
|
@ArrayMinSize(1)
|
|
roleIds: string[];
|
|
}
|
|
```
|
|
|
|
### Assign Users to Role DTO
|
|
|
|
```typescript
|
|
// dto/assign-users.dto.ts
|
|
import { IsArray, IsUUID, ArrayMinSize } from 'class-validator';
|
|
import { ApiProperty } from '@nestjs/swagger';
|
|
|
|
export class AssignUsersToRoleDto {
|
|
@ApiProperty({
|
|
description: 'IDs de usuarios a asignar al rol',
|
|
example: ['user-uuid-1', 'user-uuid-2'],
|
|
type: [String],
|
|
})
|
|
@IsArray()
|
|
@IsUUID('4', { each: true })
|
|
@ArrayMinSize(1)
|
|
userIds: string[];
|
|
}
|
|
```
|
|
|
|
### Role Query DTO
|
|
|
|
```typescript
|
|
// dto/role-query.dto.ts
|
|
import { IsOptional, IsString, IsEnum, IsBoolean } from 'class-validator';
|
|
import { Transform } from 'class-transformer';
|
|
import { PaginationDto } from '../../../common/dto/pagination.dto';
|
|
|
|
export enum RoleType {
|
|
ALL = 'all',
|
|
BUILT_IN = 'builtin',
|
|
CUSTOM = 'custom',
|
|
}
|
|
|
|
export class RoleQueryDto extends PaginationDto {
|
|
@IsOptional()
|
|
@IsString()
|
|
search?: string;
|
|
|
|
@IsOptional()
|
|
@IsEnum(RoleType)
|
|
type?: RoleType = RoleType.ALL;
|
|
|
|
@IsOptional()
|
|
@Transform(({ value }) => value === 'true')
|
|
@IsBoolean()
|
|
includeInactive?: boolean = false;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Response DTOs
|
|
|
|
### Role Response DTO
|
|
|
|
```typescript
|
|
// dto/role-response.dto.ts
|
|
import { ApiProperty } from '@nestjs/swagger';
|
|
|
|
export class RoleResponseDto {
|
|
@ApiProperty()
|
|
id: string;
|
|
|
|
@ApiProperty()
|
|
name: string;
|
|
|
|
@ApiProperty()
|
|
slug: string;
|
|
|
|
@ApiProperty()
|
|
description: string;
|
|
|
|
@ApiProperty()
|
|
isBuiltIn: boolean;
|
|
|
|
@ApiProperty()
|
|
isActive: boolean;
|
|
|
|
@ApiProperty()
|
|
usersCount: number;
|
|
|
|
@ApiProperty()
|
|
permissionsCount: number;
|
|
|
|
@ApiProperty()
|
|
createdAt: Date;
|
|
}
|
|
|
|
export class RoleDetailResponseDto extends RoleResponseDto {
|
|
@ApiProperty({ type: [PermissionGroupDto] })
|
|
permissions: PermissionGroupDto[];
|
|
}
|
|
|
|
export class PermissionGroupDto {
|
|
@ApiProperty()
|
|
module: string;
|
|
|
|
@ApiProperty()
|
|
moduleName: string;
|
|
|
|
@ApiProperty({ type: [PermissionDto] })
|
|
permissions: PermissionDto[];
|
|
}
|
|
|
|
export class PermissionDto {
|
|
@ApiProperty()
|
|
id: string;
|
|
|
|
@ApiProperty()
|
|
code: string;
|
|
|
|
@ApiProperty()
|
|
name: string;
|
|
|
|
@ApiProperty()
|
|
description: string;
|
|
}
|
|
```
|
|
|
|
### Effective Permissions Response
|
|
|
|
```typescript
|
|
// dto/effective-permissions.dto.ts
|
|
export class EffectivePermissionsDto {
|
|
@ApiProperty({ description: 'Roles del usuario' })
|
|
roles: string[];
|
|
|
|
@ApiProperty({ description: 'Permisos directos de los roles' })
|
|
direct: string[];
|
|
|
|
@ApiProperty({ description: 'Permisos heredados via wildcards' })
|
|
inherited: string[];
|
|
|
|
@ApiProperty({ description: 'Todos los permisos efectivos' })
|
|
all: string[];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Servicios
|
|
|
|
### Roles Service
|
|
|
|
```typescript
|
|
// services/roles.service.ts
|
|
import { Injectable, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, In, Like, Not, IsNull } from 'typeorm';
|
|
import { Role } from '../entities/role.entity';
|
|
import { Permission } from '../entities/permission.entity';
|
|
import { UserRole } from '../entities/user-role.entity';
|
|
import { CreateRoleDto, UpdateRoleDto, RoleQueryDto, RoleType } from '../dto';
|
|
import { RbacCacheService } from './rbac-cache.service';
|
|
import { slugify } from '../../../common/utils/slugify';
|
|
|
|
@Injectable()
|
|
export class RolesService {
|
|
constructor(
|
|
@InjectRepository(Role)
|
|
private roleRepository: Repository<Role>,
|
|
@InjectRepository(Permission)
|
|
private permissionRepository: Repository<Permission>,
|
|
@InjectRepository(UserRole)
|
|
private userRoleRepository: Repository<UserRole>,
|
|
private cacheService: RbacCacheService,
|
|
) {}
|
|
|
|
async create(tenantId: string, dto: CreateRoleDto, userId: string): Promise<Role> {
|
|
// Validar nombre unico
|
|
const existing = await this.roleRepository.findOne({
|
|
where: { tenantId, name: dto.name },
|
|
});
|
|
if (existing) {
|
|
throw new ConflictException('Ya existe un rol con este nombre');
|
|
}
|
|
|
|
// Validar limite de roles por tenant
|
|
const count = await this.roleRepository.count({
|
|
where: { tenantId, isBuiltIn: false },
|
|
});
|
|
if (count >= 50) {
|
|
throw new BadRequestException('Limite de roles alcanzado (50)');
|
|
}
|
|
|
|
// Validar permisos existen
|
|
const permissions = await this.permissionRepository.findBy({
|
|
id: In(dto.permissionIds),
|
|
});
|
|
if (permissions.length !== dto.permissionIds.length) {
|
|
throw new BadRequestException('Algunos permisos no existen');
|
|
}
|
|
|
|
// Crear rol
|
|
const role = this.roleRepository.create({
|
|
tenantId,
|
|
name: dto.name,
|
|
slug: slugify(dto.name),
|
|
description: dto.description,
|
|
permissions,
|
|
createdBy: userId,
|
|
});
|
|
|
|
return this.roleRepository.save(role);
|
|
}
|
|
|
|
async findAll(tenantId: string, query: RoleQueryDto) {
|
|
const qb = this.roleRepository
|
|
.createQueryBuilder('role')
|
|
.leftJoin('role.userRoles', 'ur')
|
|
.leftJoin('role.permissions', 'p')
|
|
.select([
|
|
'role.id',
|
|
'role.name',
|
|
'role.slug',
|
|
'role.description',
|
|
'role.isBuiltIn',
|
|
'role.isActive',
|
|
'role.createdAt',
|
|
])
|
|
.addSelect('COUNT(DISTINCT ur.userId)', 'usersCount')
|
|
.addSelect('COUNT(DISTINCT p.id)', 'permissionsCount')
|
|
.where('role.tenantId = :tenantId', { tenantId })
|
|
.groupBy('role.id');
|
|
|
|
// Filtro por tipo
|
|
if (query.type === RoleType.BUILT_IN) {
|
|
qb.andWhere('role.isBuiltIn = true');
|
|
} else if (query.type === RoleType.CUSTOM) {
|
|
qb.andWhere('role.isBuiltIn = false');
|
|
}
|
|
|
|
// Filtro por activo
|
|
if (!query.includeInactive) {
|
|
qb.andWhere('role.isActive = true');
|
|
}
|
|
|
|
// Busqueda
|
|
if (query.search) {
|
|
qb.andWhere('(role.name ILIKE :search OR role.description ILIKE :search)', {
|
|
search: `%${query.search}%`,
|
|
});
|
|
}
|
|
|
|
// Ordenar: built-in primero, luego por nombre
|
|
qb.orderBy('role.isBuiltIn', 'DESC').addOrderBy('role.name', 'ASC');
|
|
|
|
// Paginacion
|
|
const total = await qb.getCount();
|
|
const data = await qb
|
|
.offset((query.page - 1) * query.limit)
|
|
.limit(query.limit)
|
|
.getRawAndEntities();
|
|
|
|
return {
|
|
data: data.entities.map((role, i) => ({
|
|
...role,
|
|
usersCount: parseInt(data.raw[i].usersCount),
|
|
permissionsCount: parseInt(data.raw[i].permissionsCount),
|
|
})),
|
|
meta: {
|
|
total,
|
|
page: query.page,
|
|
limit: query.limit,
|
|
totalPages: Math.ceil(total / query.limit),
|
|
hasNext: query.page * query.limit < total,
|
|
hasPrev: query.page > 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
async findOne(tenantId: string, id: string): Promise<Role> {
|
|
const role = await this.roleRepository.findOne({
|
|
where: { id, tenantId },
|
|
relations: ['permissions'],
|
|
});
|
|
|
|
if (!role) {
|
|
throw new NotFoundException('Rol no encontrado');
|
|
}
|
|
|
|
// Agregar conteo de usuarios
|
|
const usersCount = await this.userRoleRepository.count({
|
|
where: { roleId: id },
|
|
});
|
|
role.usersCount = usersCount;
|
|
|
|
return role;
|
|
}
|
|
|
|
async update(
|
|
tenantId: string,
|
|
id: string,
|
|
dto: UpdateRoleDto,
|
|
userId: string,
|
|
): Promise<Role> {
|
|
const role = await this.findOne(tenantId, id);
|
|
|
|
// Validar si es built-in
|
|
if (role.isBuiltIn) {
|
|
// Solo permitir agregar permisos, no quitar ni cambiar nombre
|
|
if (dto.name && dto.name !== role.name) {
|
|
throw new BadRequestException('No se puede cambiar el nombre de roles del sistema');
|
|
}
|
|
if (dto.permissionIds) {
|
|
// Validar que solo se agregan, no se quitan
|
|
const currentPermIds = role.permissions.map(p => p.id);
|
|
const removedPerms = currentPermIds.filter(id => !dto.permissionIds.includes(id));
|
|
if (removedPerms.length > 0) {
|
|
throw new BadRequestException('No se pueden quitar permisos de roles del sistema');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validar nombre unico si cambio
|
|
if (dto.name && dto.name !== role.name) {
|
|
const existing = await this.roleRepository.findOne({
|
|
where: { tenantId, name: dto.name, id: Not(id) },
|
|
});
|
|
if (existing) {
|
|
throw new ConflictException('Ya existe un rol con este nombre');
|
|
}
|
|
role.name = dto.name;
|
|
role.slug = slugify(dto.name);
|
|
}
|
|
|
|
if (dto.description !== undefined) {
|
|
role.description = dto.description;
|
|
}
|
|
|
|
if (dto.isActive !== undefined && !role.isBuiltIn) {
|
|
role.isActive = dto.isActive;
|
|
}
|
|
|
|
if (dto.permissionIds) {
|
|
const permissions = await this.permissionRepository.findBy({
|
|
id: In(dto.permissionIds),
|
|
});
|
|
role.permissions = permissions;
|
|
}
|
|
|
|
role.updatedBy = userId;
|
|
|
|
const updated = await this.roleRepository.save(role);
|
|
|
|
// Invalidar cache de usuarios con este rol
|
|
await this.invalidateCacheForRole(id);
|
|
|
|
return updated;
|
|
}
|
|
|
|
async remove(
|
|
tenantId: string,
|
|
id: string,
|
|
userId: string,
|
|
reassignTo?: string,
|
|
): Promise<void> {
|
|
const role = await this.findOne(tenantId, id);
|
|
|
|
if (role.isBuiltIn) {
|
|
throw new BadRequestException('No se pueden eliminar roles del sistema');
|
|
}
|
|
|
|
// Reasignar usuarios si se especifico
|
|
if (reassignTo && role.usersCount > 0) {
|
|
const targetRole = await this.findOne(tenantId, reassignTo);
|
|
await this.userRoleRepository.update(
|
|
{ roleId: id },
|
|
{ roleId: targetRole.id },
|
|
);
|
|
} else if (role.usersCount > 0) {
|
|
// Eliminar asignaciones
|
|
await this.userRoleRepository.delete({ roleId: id });
|
|
}
|
|
|
|
// Soft delete
|
|
role.deletedBy = userId;
|
|
await this.roleRepository.save(role);
|
|
await this.roleRepository.softDelete(id);
|
|
|
|
// Invalidar cache
|
|
await this.invalidateCacheForRole(id);
|
|
}
|
|
|
|
private async invalidateCacheForRole(roleId: string): Promise<void> {
|
|
const userRoles = await this.userRoleRepository.find({
|
|
where: { roleId },
|
|
select: ['userId'],
|
|
});
|
|
|
|
for (const ur of userRoles) {
|
|
await this.cacheService.invalidateUserPermissions(ur.userId);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Permissions Service
|
|
|
|
```typescript
|
|
// services/permissions.service.ts
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, In } from 'typeorm';
|
|
import { Permission } from '../entities/permission.entity';
|
|
import { UserRole } from '../entities/user-role.entity';
|
|
import { RbacCacheService } from './rbac-cache.service';
|
|
|
|
@Injectable()
|
|
export class PermissionsService {
|
|
private moduleNames: Record<string, string> = {
|
|
auth: 'Autenticacion',
|
|
users: 'Gestion de Usuarios',
|
|
roles: 'Roles y Permisos',
|
|
tenants: 'Multi-Tenancy',
|
|
settings: 'Configuracion',
|
|
audit: 'Auditoria',
|
|
reports: 'Reportes',
|
|
financial: 'Modulo Financiero',
|
|
inventory: 'Inventario',
|
|
};
|
|
|
|
constructor(
|
|
@InjectRepository(Permission)
|
|
private permissionRepository: Repository<Permission>,
|
|
@InjectRepository(UserRole)
|
|
private userRoleRepository: Repository<UserRole>,
|
|
private cacheService: RbacCacheService,
|
|
) {}
|
|
|
|
async findAll(search?: string): Promise<PermissionGroupDto[]> {
|
|
let qb = this.permissionRepository
|
|
.createQueryBuilder('p')
|
|
.where('p.isDeprecated = false')
|
|
.orderBy('p.module', 'ASC')
|
|
.addOrderBy('p.sortOrder', 'ASC');
|
|
|
|
if (search) {
|
|
qb = qb.andWhere(
|
|
'(p.code ILIKE :search OR p.name ILIKE :search)',
|
|
{ search: `%${search}%` },
|
|
);
|
|
}
|
|
|
|
const permissions = await qb.getMany();
|
|
|
|
// Agrupar por modulo
|
|
const grouped = new Map<string, Permission[]>();
|
|
for (const perm of permissions) {
|
|
if (!grouped.has(perm.module)) {
|
|
grouped.set(perm.module, []);
|
|
}
|
|
grouped.get(perm.module).push(perm);
|
|
}
|
|
|
|
return Array.from(grouped.entries()).map(([module, perms]) => ({
|
|
module,
|
|
moduleName: this.moduleNames[module] || module,
|
|
permissions: perms.map(p => ({
|
|
id: p.id,
|
|
code: p.code,
|
|
name: p.name,
|
|
description: p.description,
|
|
})),
|
|
}));
|
|
}
|
|
|
|
async getEffectivePermissions(userId: string): Promise<EffectivePermissionsDto> {
|
|
// Intentar desde cache
|
|
const cached = await this.cacheService.getUserPermissions(userId);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
// Obtener roles del usuario
|
|
const userRoles = await this.userRoleRepository.find({
|
|
where: { userId },
|
|
relations: ['role', 'role.permissions'],
|
|
});
|
|
|
|
const roles = userRoles
|
|
.filter(ur => !ur.expiresAt || ur.expiresAt > new Date())
|
|
.filter(ur => ur.role.isActive)
|
|
.map(ur => ur.role.slug);
|
|
|
|
// Recolectar permisos directos
|
|
const directPerms = new Set<string>();
|
|
for (const ur of userRoles) {
|
|
if (ur.role.isActive) {
|
|
for (const perm of ur.role.permissions) {
|
|
directPerms.add(perm.code);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Expandir wildcards
|
|
const allPerms = await this.expandWildcards(Array.from(directPerms));
|
|
|
|
// Calcular heredados
|
|
const inherited = allPerms.filter(p => !directPerms.has(p));
|
|
|
|
const result: EffectivePermissionsDto = {
|
|
roles,
|
|
direct: Array.from(directPerms),
|
|
inherited,
|
|
all: allPerms,
|
|
};
|
|
|
|
// Guardar en cache
|
|
await this.cacheService.setUserPermissions(userId, result);
|
|
|
|
return result;
|
|
}
|
|
|
|
async userHasPermission(userId: string, permission: string): Promise<boolean> {
|
|
const effective = await this.getEffectivePermissions(userId);
|
|
return effective.all.includes(permission);
|
|
}
|
|
|
|
async userHasAnyPermission(userId: string, permissions: string[]): Promise<boolean> {
|
|
const effective = await this.getEffectivePermissions(userId);
|
|
return permissions.some(p => effective.all.includes(p));
|
|
}
|
|
|
|
async userHasAllPermissions(userId: string, permissions: string[]): Promise<boolean> {
|
|
const effective = await this.getEffectivePermissions(userId);
|
|
return permissions.every(p => effective.all.includes(p));
|
|
}
|
|
|
|
private async expandWildcards(permissions: string[]): Promise<string[]> {
|
|
const result = new Set<string>(permissions);
|
|
|
|
// Encontrar wildcards
|
|
const wildcards = permissions.filter(p => p.endsWith(':*'));
|
|
|
|
if (wildcards.length === 0) {
|
|
return Array.from(result);
|
|
}
|
|
|
|
// Obtener permisos hijos de wildcards
|
|
for (const wildcard of wildcards) {
|
|
const module = wildcard.replace(':*', '');
|
|
const children = await this.permissionRepository.find({
|
|
where: { module, isDeprecated: false },
|
|
});
|
|
children.forEach(c => result.add(c.code));
|
|
}
|
|
|
|
return Array.from(result);
|
|
}
|
|
}
|
|
```
|
|
|
|
### RBAC Cache Service
|
|
|
|
```typescript
|
|
// services/rbac-cache.service.ts
|
|
import { Injectable, Inject } from '@nestjs/common';
|
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|
import { Cache } from 'cache-manager';
|
|
import { EffectivePermissionsDto } from '../dto';
|
|
|
|
@Injectable()
|
|
export class RbacCacheService {
|
|
private readonly CACHE_TTL = 300000; // 5 minutos
|
|
private readonly PREFIX = 'rbac:permissions:';
|
|
|
|
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
|
|
|
|
async getUserPermissions(userId: string): Promise<EffectivePermissionsDto | null> {
|
|
const key = `${this.PREFIX}${userId}`;
|
|
return this.cache.get<EffectivePermissionsDto>(key);
|
|
}
|
|
|
|
async setUserPermissions(
|
|
userId: string,
|
|
permissions: EffectivePermissionsDto,
|
|
): Promise<void> {
|
|
const key = `${this.PREFIX}${userId}`;
|
|
await this.cache.set(key, permissions, this.CACHE_TTL);
|
|
}
|
|
|
|
async invalidateUserPermissions(userId: string): Promise<void> {
|
|
const key = `${this.PREFIX}${userId}`;
|
|
await this.cache.del(key);
|
|
}
|
|
|
|
async invalidateAll(): Promise<void> {
|
|
// En produccion: usar patron de keys o pub/sub
|
|
// await this.cache.reset();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Guards y Decoradores
|
|
|
|
### Decoradores
|
|
|
|
```typescript
|
|
// decorators/permissions.decorator.ts
|
|
import { SetMetadata } from '@nestjs/common';
|
|
|
|
export const PERMISSIONS_KEY = 'permissions';
|
|
export const PERMISSION_MODE_KEY = 'permissionMode';
|
|
|
|
export interface PermissionMetadata {
|
|
permissions: string[];
|
|
mode: 'AND' | 'OR';
|
|
}
|
|
|
|
export const Permissions = (...permissions: string[]) =>
|
|
SetMetadata(PERMISSIONS_KEY, { permissions, mode: 'AND' } as PermissionMetadata);
|
|
|
|
export const AnyPermission = (...permissions: string[]) =>
|
|
SetMetadata(PERMISSIONS_KEY, { permissions, mode: 'OR' } as PermissionMetadata);
|
|
|
|
// decorators/roles.decorator.ts
|
|
export const ROLES_KEY = 'roles';
|
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
|
|
|
// decorators/public.decorator.ts
|
|
export const IS_PUBLIC_KEY = 'isPublic';
|
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
|
|
|
// decorators/owner-or-permission.decorator.ts
|
|
export const OWNER_OR_PERMISSION_KEY = 'ownerOrPermission';
|
|
export const OwnerOrPermission = (permission: string) =>
|
|
SetMetadata(OWNER_OR_PERMISSION_KEY, permission);
|
|
```
|
|
|
|
### RBAC Guard
|
|
|
|
```typescript
|
|
// guards/rbac.guard.ts
|
|
import {
|
|
Injectable,
|
|
CanActivate,
|
|
ExecutionContext,
|
|
ForbiddenException,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { PermissionsService } from '../services/permissions.service';
|
|
import {
|
|
IS_PUBLIC_KEY,
|
|
PERMISSIONS_KEY,
|
|
ROLES_KEY,
|
|
PermissionMetadata,
|
|
} from '../decorators';
|
|
|
|
@Injectable()
|
|
export class RbacGuard implements CanActivate {
|
|
private readonly logger = new Logger(RbacGuard.name);
|
|
|
|
constructor(
|
|
private reflector: Reflector,
|
|
private permissionsService: PermissionsService,
|
|
) {}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
// Verificar si es ruta publica
|
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
context.getHandler(),
|
|
context.getClass(),
|
|
]);
|
|
if (isPublic) return true;
|
|
|
|
const request = context.switchToHttp().getRequest();
|
|
const user = request.user;
|
|
|
|
if (!user) {
|
|
throw new ForbiddenException('No autenticado');
|
|
}
|
|
|
|
// Super Admin bypass
|
|
const effectivePerms = await this.permissionsService.getEffectivePermissions(user.id);
|
|
if (effectivePerms.roles.includes('super_admin')) {
|
|
return true;
|
|
}
|
|
|
|
// Verificar roles requeridos
|
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
|
context.getHandler(),
|
|
context.getClass(),
|
|
]);
|
|
if (requiredRoles && requiredRoles.length > 0) {
|
|
const hasRole = requiredRoles.some(role => effectivePerms.roles.includes(role));
|
|
if (!hasRole) {
|
|
this.logDenied(user.id, request, 'roles', requiredRoles);
|
|
throw new ForbiddenException('No tienes permiso para realizar esta accion');
|
|
}
|
|
}
|
|
|
|
// Verificar permisos requeridos
|
|
const permMetadata = this.reflector.getAllAndOverride<PermissionMetadata>(
|
|
PERMISSIONS_KEY,
|
|
[context.getHandler(), context.getClass()],
|
|
);
|
|
|
|
if (!permMetadata) return true;
|
|
|
|
const { permissions, mode } = permMetadata;
|
|
|
|
let hasPermission: boolean;
|
|
if (mode === 'AND') {
|
|
hasPermission = await this.permissionsService.userHasAllPermissions(
|
|
user.id,
|
|
permissions,
|
|
);
|
|
} else {
|
|
hasPermission = await this.permissionsService.userHasAnyPermission(
|
|
user.id,
|
|
permissions,
|
|
);
|
|
}
|
|
|
|
if (!hasPermission) {
|
|
this.logDenied(user.id, request, 'permissions', permissions);
|
|
throw new ForbiddenException('No tienes permiso para realizar esta accion');
|
|
}
|
|
|
|
// Adjuntar permisos al request para uso posterior
|
|
request.userPermissions = effectivePerms;
|
|
|
|
return true;
|
|
}
|
|
|
|
private logDenied(
|
|
userId: string,
|
|
request: any,
|
|
type: string,
|
|
required: string[],
|
|
): void {
|
|
this.logger.warn({
|
|
message: 'Access denied',
|
|
userId,
|
|
endpoint: `${request.method} ${request.url}`,
|
|
requiredType: type,
|
|
required,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### Owner Guard
|
|
|
|
```typescript
|
|
// guards/owner.guard.ts
|
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { OWNER_OR_PERMISSION_KEY } from '../decorators';
|
|
import { PermissionsService } from '../services/permissions.service';
|
|
|
|
@Injectable()
|
|
export class OwnerGuard implements CanActivate {
|
|
constructor(
|
|
private reflector: Reflector,
|
|
private permissionsService: PermissionsService,
|
|
) {}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const permission = this.reflector.get<string>(
|
|
OWNER_OR_PERMISSION_KEY,
|
|
context.getHandler(),
|
|
);
|
|
|
|
if (!permission) return true;
|
|
|
|
const request = context.switchToHttp().getRequest();
|
|
const user = request.user;
|
|
const resourceId = request.params.id;
|
|
|
|
// Verificar si tiene permiso
|
|
const hasPermission = await this.permissionsService.userHasPermission(
|
|
user.id,
|
|
permission,
|
|
);
|
|
if (hasPermission) return true;
|
|
|
|
// Verificar si es owner
|
|
return user.id === resourceId;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Controllers
|
|
|
|
### Roles Controller
|
|
|
|
```typescript
|
|
// controllers/roles.controller.ts
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Patch,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
ParseUUIDPipe,
|
|
} from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
|
import { TenantGuard } from '../../tenants/guards/tenant.guard';
|
|
import { RbacGuard } from '../guards/rbac.guard';
|
|
import { Permissions } from '../decorators';
|
|
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
|
import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator';
|
|
import { RolesService } from '../services/roles.service';
|
|
import { CreateRoleDto, UpdateRoleDto, RoleQueryDto } from '../dto';
|
|
|
|
@ApiTags('Roles')
|
|
@ApiBearerAuth()
|
|
@Controller('api/v1/roles')
|
|
@UseGuards(JwtAuthGuard, TenantGuard, RbacGuard)
|
|
export class RolesController {
|
|
constructor(private rolesService: RolesService) {}
|
|
|
|
@Post()
|
|
@Permissions('roles:create')
|
|
@ApiOperation({ summary: 'Crear un nuevo rol' })
|
|
create(
|
|
@CurrentTenant() tenantId: string,
|
|
@CurrentUser('id') userId: string,
|
|
@Body() dto: CreateRoleDto,
|
|
) {
|
|
return this.rolesService.create(tenantId, dto, userId);
|
|
}
|
|
|
|
@Get()
|
|
@Permissions('roles:read')
|
|
@ApiOperation({ summary: 'Listar roles' })
|
|
findAll(
|
|
@CurrentTenant() tenantId: string,
|
|
@Query() query: RoleQueryDto,
|
|
) {
|
|
return this.rolesService.findAll(tenantId, query);
|
|
}
|
|
|
|
@Get(':id')
|
|
@Permissions('roles:read')
|
|
@ApiOperation({ summary: 'Obtener detalle de un rol' })
|
|
findOne(
|
|
@CurrentTenant() tenantId: string,
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
) {
|
|
return this.rolesService.findOne(tenantId, id);
|
|
}
|
|
|
|
@Patch(':id')
|
|
@Permissions('roles:update')
|
|
@ApiOperation({ summary: 'Actualizar un rol' })
|
|
update(
|
|
@CurrentTenant() tenantId: string,
|
|
@CurrentUser('id') userId: string,
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@Body() dto: UpdateRoleDto,
|
|
) {
|
|
return this.rolesService.update(tenantId, id, dto, userId);
|
|
}
|
|
|
|
@Delete(':id')
|
|
@Permissions('roles:delete')
|
|
@ApiOperation({ summary: 'Eliminar un rol' })
|
|
remove(
|
|
@CurrentTenant() tenantId: string,
|
|
@CurrentUser('id') userId: string,
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@Query('reassignTo') reassignTo?: string,
|
|
) {
|
|
return this.rolesService.remove(tenantId, id, userId, reassignTo);
|
|
}
|
|
|
|
@Get(':id/users')
|
|
@Permissions('roles:read')
|
|
@ApiOperation({ summary: 'Listar usuarios de un rol' })
|
|
findUsers(
|
|
@CurrentTenant() tenantId: string,
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@Query() query: PaginationDto,
|
|
) {
|
|
return this.rolesService.findUsers(tenantId, id, query);
|
|
}
|
|
|
|
@Post(':id/users')
|
|
@Permissions('roles:assign')
|
|
@ApiOperation({ summary: 'Asignar rol a usuarios' })
|
|
assignUsers(
|
|
@CurrentTenant() tenantId: string,
|
|
@CurrentUser('id') userId: string,
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@Body() dto: AssignUsersToRoleDto,
|
|
) {
|
|
return this.rolesService.assignUsers(tenantId, id, dto.userIds, userId);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Permissions Controller
|
|
|
|
```typescript
|
|
// controllers/permissions.controller.ts
|
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
|
import { RbacGuard } from '../guards/rbac.guard';
|
|
import { Permissions } from '../decorators';
|
|
import { PermissionsService } from '../services/permissions.service';
|
|
|
|
@ApiTags('Permissions')
|
|
@ApiBearerAuth()
|
|
@Controller('api/v1/permissions')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
export class PermissionsController {
|
|
constructor(private permissionsService: PermissionsService) {}
|
|
|
|
@Get()
|
|
@Permissions('permissions:read')
|
|
@ApiOperation({ summary: 'Listar permisos agrupados por modulo' })
|
|
findAll(@Query('search') search?: string) {
|
|
return this.permissionsService.findAll(search);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Endpoints Summary
|
|
|
|
| Metodo | Endpoint | Permiso | Descripcion |
|
|
|--------|----------|---------|-------------|
|
|
| POST | /api/v1/roles | roles:create | Crear rol |
|
|
| GET | /api/v1/roles | roles:read | Listar roles |
|
|
| GET | /api/v1/roles/:id | roles:read | Detalle de rol |
|
|
| PATCH | /api/v1/roles/:id | roles:update | Actualizar rol |
|
|
| DELETE | /api/v1/roles/:id | roles:delete | Eliminar rol |
|
|
| GET | /api/v1/roles/:id/users | roles:read | Usuarios del rol |
|
|
| POST | /api/v1/roles/:id/users | roles:assign | Asignar usuarios |
|
|
| GET | /api/v1/permissions | permissions:read | Listar permisos |
|
|
| PUT | /api/v1/users/:id/roles | roles:assign | Asignar roles a usuario |
|
|
| GET | /api/v1/users/:id/permissions | - | Permisos efectivos |
|
|
|
|
**Total: 10 endpoints**
|
|
|
|
---
|
|
|
|
## Historial
|
|
|
|
| Version | Fecha | Autor | Cambios |
|
|
|---------|-------|-------|---------|
|
|
| 1.0 | 2025-12-05 | System | Creacion inicial |
|