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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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 |