Control de Acceso Basado en Permisos (Guards)
Una vez resuelta la autenticación de usuarios y la emisión de tokens JWT, el siguiente desafío arquitectónico consiste en gobernar qué recursos y operaciones tiene permitido ejecutar cada usuario dentro del sistema.
En esta guía final implementaremos un esquema de autorización granular basado en permisos (PBAC - Permission-Based Access Control), construyendo un decorador personalizado @Permissions, un guardia especializado PermissionsGuard con Reflector y la protección declarativa de controladores en NestJS.
1. De Roles a Permisos Granulares (RBAC vs. PBAC)
En aplicaciones iniciales suele ser tentador validar directamente los nombres de los roles en las rutas (por ejemplo: @Roles('admin')). No obstante, este enfoque rígido presenta severas desventajas en sistemas en crecimiento:
- Explosión de Roles: Si un usuario con rol "editor" necesita eliminar comentarios pero no artículos, surge la tentación de crear roles como "editor_con_borrado_de_comentarios".
- Acoplamiento Fuerte: El código del controlador queda atado a los nombres específicos de los roles del negocio, impidiendo que los administradores reconfiguren dinámicamente qué puede hacer un rol desde una interfaz web.
La Solución: Autorización Basada en Permisos Atómicos (PBAC)
- Las Rutas y Controladores únicamente declaran qué permiso atómico requieren para ejecutarse (por ejemplo:
users:read,users:create,products:delete). - Los Roles agrupan conjuntos arbitrarios de permisos en la base de datos.
- Los Usuarios tienen asignado un rol.
2. Creación del Decorador @Permissions
NestJS permite asociar metadatos personalizados a cualquier controlador o manejador de ruta utilizando la función SetMetadata provista por @nestjs/common.
Crea el archivo decorador en tu módulo de autenticación o autorización:
import { SetMetadata } from '@nestjs/common';
/**
* Clave constante utilizada para almacenar y extraer metadatos de permisos.
*/
export const PERMISSIONS_KEY = 'permissions';
/**
* Decorador personalizado para asociar permisos requeridos a un endpoint.
* Ejemplo: @Permissions('users:read', 'users:delete')
*/
export const Permissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
¿Cómo funciona en tiempo de ejecución?
Cuando TypeScript compila este decorador, añade una entrada en la tabla de metadatos de reflexión (Reflect) asociada a la función del controlador. El decorador en sí no ejecuta validaciones; solo etiqueta la ruta con los requisitos exigidos.
3. Implementación del PermissionsGuard
El PermissionsGuard implementa CanActivate y utiliza la clase utilitaria Reflector de NestJS para leer los metadatos inyectados por @Permissions.
Código del Guardia
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
import { User } from '../../users/entities/user.entity';
/**
* Extensión de la interfaz Request de Express para tipar el usuario autenticado.
*/
interface AuthenticatedRequest extends Request {
user?: User;
}
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 1. Extraemos los permisos requeridos del método manejador
const requiredPermissions = this.reflector.get<string[]>(
PERMISSIONS_KEY,
context.getHandler(),
);
// Si la ruta no tiene el decorador @Permissions, se considera de libre acceso (o solo requiere autenticación)
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
// 2. Obtenemos el objeto Request HTTP subyacente
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
// Si req.user no existe, AuthGuard no se ejecutó previamente o el token no era válido
if (!user) {
throw new UnauthorizedException('Usuario no autenticado en la solicitud');
}
// 3. Obtenemos la lista plana de permisos del usuario a partir de su rol
const userPermissions =
user.role?.rolePermissions?.map((rp) => rp.permission.name) ?? [];
// 4. Verificamos que el usuario posea TODOS los permisos requeridos (condición AND)
const hasAllRequiredPermissions = requiredPermissions.every((permission) =>
userPermissions.includes(permission),
);
// 5. Si carece de al menos un permiso, bloqueamos el acceso
if (!hasAllRequiredPermissions) {
throw new ForbiddenException(
'Acceso denegado: No cuentas con los permisos suficientes para esta acción',
);
}
return true;
}
}
En este diseño utilizamos requiredPermissions.every(...), lo que significa que el usuario debe poseer todos los permisos indicados en el decorador. Si en tu caso de uso requieres que baste con cumplir al menos uno, puedes sustituirlo por requiredPermissions.some(...).
4. Uso de Guards en Controladores
Para proteger un endpoint, aplicamos los guards en cascada utilizando el decorador @UseGuards().
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
HttpCode,
HttpStatus,
Query,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { PermissionsGuard } from '../auth/guards/permissions.guard';
@Controller('users')
// Se aplican en orden secuencial a todos los endpoints del controlador
@UseGuards(AuthGuard('jwt'), PermissionsGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
@HttpCode(HttpStatus.OK)
@Permissions('users:read')
async findAll(@Query('username') username?: string) {
return this.usersService.findAll(username);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@Permissions('users:create')
async create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Permissions('users:delete')
async remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}
El orden de los guards pasados a @UseGuards(AuthGuard('jwt'), PermissionsGuard) es fundamental:
- Primero se ejecuta
AuthGuard('jwt'): valida el token de la cabecera HTTP e inyecta la entidad enreq.user. - Luego se ejecuta
PermissionsGuard: leereq.userpara comprobar sus permisos. Si inviertes el orden,PermissionsGuardfallará siempre arrojando un error de usuario no autenticado.
5. Pruebas y Diagnóstico de Respuestas
Para probar las reglas de autorización, envía solicitudes utilizando herramientas como cURL, Postman o Insomnia.
Caso 1: Acceso Concedido (Token Válido + Permiso Correcto)
curl -X GET http://localhost:3000/users \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsIn..."
Respuesta HTTP 200 OK:
[
{ "id": 1, "email": "ana@icesi.edu.co", "role": { "name": "admin" } }
]
Caso 2: Rechazo por Falta de Autenticación (Sin Token o Token Expirado)
curl -X GET http://localhost:3000/users
Respuesta HTTP 401 Unauthorized:
{
"message": "Unauthorized",
"statusCode": 401
}
Caso 3: Rechazo por Permisos Insuficientes (Token Válido de Usuario sin Privilegio)
Si un usuario con rol "invitado" intenta acceder a una ruta que requiere users:read:
Respuesta HTTP 403 Forbidden:
{
"message": "Acceso denegado: No cuentas con los permisos suficientes para esta acción",
"error": "Forbidden",
"statusCode": 403
}
Solución a Problemas Comunes (Errores 500)
Si experimentas respuestas con código HTTP 500 Internal Server Error, la causa más frecuente en este punto es que user.role o user.role.rolePermissions son undefined al intentar mapearlos en el guard.
Asegúrate de que la consulta ejecutada en JwtStrategy.validate(payload) o en UsersService.findById() incluya explícitamente las relaciones de TypeORM:
async findById(id: number): Promise<User | null> {
return this.userRepository.findOne({
where: { id },
relations: [
'role',
'role.rolePermissions',
'role.rolePermissions.permission',
],
});
}