🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

Authorization en NestJS: Guards, Roles y RBAC

Serie NestJS #11 : Guards custom, decorador @Roles(), Reflector, RBAC, permisos granulares con CASL y control de acceso real

Escrito por domin el 5 de abril de 2026

HOLABONDIEQUETAL Vamos con el post número 11 de la serie NestJS y segundo del bloque de Seguridad. En el post anterior montamos la autenticación completita con Passport, JWT, refresh tokens, @GetUser()… Ya sabemos quién es el usuario y hoy toca responder qué puede hacer ese usuario.

Repasando lo anterior, tenemos toda la base con Docker (post 1), controllers (post 2), DI (post 3), módulos (post 4), middleware y pipeline (post 5), validación (post 6), PostgreSQL con TypeORM (posts 7-8), relaciones y migraciones (post 9) y autenticación (post 10).

Hoy nuestra super API aprende a decir Hola, eres Domin, pero no puedes borrar usuarios porque no eres admin, eres un pelele.
Vamos a ver Guards custom, decoradores de roles, RBAC, metadata con Reflector y permisos granulares con CASL, muchas cosas raras que igual no has leído o oído en tu vida pero que vamos a destripar enseguida.

EA, vamos al lío.

Escudo con candado y roles de usuario representando autorización y RBAC en NestJS.

1. Autenticación vs Autorización

Lo vimos en el post 10 pero vamos a repetirlo rápido porque es algo que se confunde con bastante facilidad:

🪪 Autenticación (AuthN)

¿QUIÉN eres? Ya resuelto: Passport + JWT. El JwtAuthGuard global verifica la identidad en cada request.

🛡️ Autorización (AuthZ)

¿QUÉ puedes hacer? Lo que resolvemos HOY. Mismo usuario autenticado, diferentes niveles de acceso según su rol o permisos.

La autenticación responde ¿eres quien dices ser?. La autorización responde vale, eres tú, pero ¿tienes permiso para esta acción?. Son capas diferentes del pipeline y se implementan con mecanismos distintos.


2. Guards en el pipeline de NestJS

¿Recuerdas el pipeline completo del post 5?

Request Middleware Guards Interceptors (pre) → Pipes → Handler → Interceptors (post) → Response

Como ya tenemos dominado el orden de ejecucicón de NestJS, sabemos que los se ejecutan justo después del middleware. Tienen la responsabilidad de decidir si la request pasa o no. Si devuelven true, la request continúa y si devuelven false o lanzan una excepción, NestJS responde con 403 Forbidden.

La diferencia con el middleware es que los Guards tienen acceso al ExecutionContext , lo que les permite saber qué handler se va a ejecutar y qué metadata tiene. Los middleware no tienen esa visibilidad.


3. Anatomía de un Guard

Está bien saber que todos los Guards implementan la interfaz CanActivate:

import { type CanActivate, type ExecutionContext } from '@nestjs/common';
import { type Observable } from 'rxjs';

export interface CanActivate {
    canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>;
}

Un Guard básico:

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';

@Injectable()
export class ExampleGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean {
        const request = context.switchToHttp().getRequest();
        // Tu lógica de autorización aquí
        return true; // o false para bloquear
    }
}

El ExecutionContext extiende ArgumentsHost y añade dos métodos clave:

// ¿Qué controller maneja esta request?
context.getClass(); // → UsersController

// ¿Qué método del controller se va a ejecutar?
context.getHandler(); // → deleteUser

Esto es lo que hace posible leer los decoradores de metadata como @Roles(). Un Guard puede inspeccionar el handler de destino y tomar decisiones basadas en sus decoradores.


4. Niveles de aplicación de Guards

Al igual que los pipes y el middleware, los Guards se pueden aplicar a diferentes niveles:

4.1. A nivel de método

@Delete(':id')
@UseGuards(RolesGuard)
remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
    return this.usersService.remove(id);
}

4.2. A nivel de controller

@Controller('admin')
@UseGuards(RolesGuard)
export class AdminController {
    // Todos los endpoints de este controller pasan por RolesGuard
}

4.3. A nivel global

// src/app.module.ts
import { APP_GUARD } from '@nestjs/core';

@Module({
    providers: [
        {
            provide: APP_GUARD,
            useClass: JwtAuthGuard, // Ya lo tenemos del post 10
        },
        {
            provide: APP_GUARD,
            useClass: RolesGuard, // Nuevo: verificar roles
        },
    ],
})
export class AppModule {}

Orden de ejecución de los Guards globales: se ejecutan en el orden en que se registran en el array de providers. Primero JwtAuthGuard (autenticación), luego RolesGuard (autorización). Si el primero falla, el segundo ni se ejecuta.

Úsalo cuando...
  • Guards globales con APP_GUARD para autenticación (JwtAuthGuard) y autorización (RolesGuard). Seguridad por defecto
  • @UseGuards() a nivel de controller cuando TODOS los endpoints requieren el mismo guard específico
Evítalo cuando...
  • @UseGuards() en cada método individual cuando se puede poner a nivel de controller o global
  • Poner lógica de autorización en el middleware. Los middleware no tienen acceso al ExecutionContext

5. El enum de roles

Antes de crear el Guard de roles, necesitamos un enum que defina los roles disponibles. Ya lo creamos en el post 7 cuando definimos la entidad User:

// src/users/enums/user-role.enum.ts
export enum UserRole {
    USER = 'user',
    EDITOR = 'editor',
    ADMIN = 'admin',
}

Y en la entidad User:

// src/users/entities/user.entity.ts
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
role: UserRole;

Cada usuario tiene exactamente un rol y está definido en el enum.


6. @SetMetadata y el decorador @Roles()

Para que un Guard sepa qué roles se requieren en cada endpoint, necesitamos metadata. NestJS usa para adjuntar datos arbitrarios a los handlers.

6.1. Versión raw con @SetMetadata

Podrías usarlo directamente:

@Delete(':id')
@SetMetadata('roles', [UserRole.ADMIN])
remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
    return this.usersService.remove(id);
}

Pero tiene dos problemas, primero que el string 'roles' es un magic string y la API es fea. La solución es endiñarle un decorador custom.

6.2. El decorador @Roles()

// src/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../users/enums/user-role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

Ahora el uso es limpio, tipado y con autocompletado:

@Delete(':id')
@Roles(UserRole.ADMIN)
remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
    return this.usersService.remove(id);
}

@Roles(UserRole.ADMIN) adjunta { roles: ['admin'] } como metadata al método remove. El Guard lo leerá con el Reflector.

6.3. Múltiples roles

// Cualquiera de estos roles puede acceder
@Roles(UserRole.ADMIN, UserRole.EDITOR)
@Patch(':id')
update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateUserDto: UpdateUserDto,
): Promise<User> {
    return this.usersService.update(id, updateUserDto);
}

Si pasas varios roles, se interpreta como OR, es decir, que los roles ADMIN o EDITOR podrán ejecutar este endpoint.


7. Reflector: leyendo la metadata

El es la pieza que conecta los decoradores (@Roles()) con los Guards. Es un helper que NestJS proporciona para leer metadata.

Tres métodos del Reflector

// 1. get() : Lee metadata de UN solo target
const roles = this.reflector.get<UserRole[]>(ROLES_KEY, context.getHandler());
// Solo lee del método. Si @Roles() está en el controller, no lo ve.

// 2. getAllAndOverride() : El handler tiene prioridad sobre el controller
const roles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
    context.getHandler(), // Primero mira aquí
    context.getClass(), // Si no hay nada en el handler, mira aquí
]);
// Si ambos tienen @Roles(), el del handler gana.

// 3. getAllAndMerge() : Combina metadata de ambos niveles
const roles = this.reflector.getAllAndMerge<UserRole[]>(ROLES_KEY, [context.getHandler(), context.getClass()]);
// Resultado: [...rolesDelHandler, ...rolesDelController]
MétodoComportamientoCuándo usarlo
get()Lee de un solo targetCuando la metadata solo se pone a nivel de método
getAllAndOverride()El handler gana sobre la claseCuando quieres que un método pueda sobreescribir la config del controller
getAllAndMerge()Combina ambos nivelesCuando quieres acumular roles del controller + método

Para nuestro RolesGuard usaremos getAllAndOverride(), esto hace que si un método tiene @Roles(), usa esos roles. Si no, hereda los del controller.


8. RolesGuard: el guard de autorización

// src/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { type UserRole } from '../../users/enums/user-role.enum';
import { type JwtPayload } from '../interfaces/jwt-payload.interface';

@Injectable()
export class RolesGuard implements CanActivate {
    constructor(private readonly reflector: Reflector) {}

    canActivate(context: ExecutionContext): boolean {
        // 1. Leer qué roles requiere este endpoint
        const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);

        // 2. Si no hay @Roles() → el endpoint no tiene restricción de rol
        if (!requiredRoles || requiredRoles.length === 0) {
            return true;
        }

        // 3. Obtener el usuario de la request (inyectado por JwtAuthGuard)
        const request = context.switchToHttp().getRequest();
        const user = request.user as JwtPayload;

        // 4. ¿El rol del usuario está en la lista de roles requeridos?
        const hasRole = requiredRoles.includes(user.role);

        if (!hasRole) {
            throw new ForbiddenException(`Se requiere uno de estos roles: ${requiredRoles.join(', ')}`);
        }

        return true;
    }
}

Amo a verlo paso a paso:

  1. Lee la metadata: getAllAndOverride() busca primero en el handler, luego en el controller. Si encuentra @Roles(UserRole.ADMIN), devuelve ['admin'].
  2. Sin @Roles() = acceso libre: Si el endpoint no tiene el decorador, el Guard deja pasar. Los endpoints sin @Roles() solo requieren autenticación (que ya cubre JwtAuthGuard).
  3. Extrae el usuario: request.user ya tiene el payload JWT porque JwtAuthGuard se ejecutó antes en el pipeline.
  4. Compara roles: Si el rol del usuario está en la lista de roles requeridos, pasa, si no, ForbiddenException (403).

¿Por qué ForbiddenException y no UnauthorizedException? 401 Unauthorized significa “no sé quién eres” (falla autenticación). 403 Forbidden significa “sé quién eres, pero no tienes permiso” (falla autorización). Usar el código HTTP correcto es importante para que los clientes sepan más o menos de que va la vaina.


9. Registro global del RolesGuard

Lo registramos como APP_GUARD junto al JwtAuthGuard que ya tenemos:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';

@Module({
    imports: [
        // ... tus módulos
    ],
    providers: [
        {
            provide: APP_GUARD,
            useClass: JwtAuthGuard, // Primero: ¿estás autenticado?
        },
        {
            provide: APP_GUARD,
            useClass: RolesGuard, // Segundo: ¿tienes el rol necesario?
        },
    ],
})
export class AppModule {}

El orden importa:

Request JwtAuthGuard (¿quién eres?) → RolesGuard (¿qué puedes hacer?) → Handler

Si JwtAuthGuard rechaza (token inválido/ausente), RolesGuard ni se ejecuta. El pipeline se va a la puta.


10. Usando @Roles() en los controllers

Así queda el UsersController con autorización aplicada y todo junto:

// src/users/users.controller.ts
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { FilterUsersDto } from './dto/filter-users.dto';
import { Roles } from '../auth/decorators/roles.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { GetUser } from '../auth/decorators/get-user.decorator';
import { UserRole } from './enums/user-role.enum';
import { type User } from './entities/user.entity';
import { type JwtPayload } from '../auth/interfaces/jwt-payload.interface';
import { type PaginatedResult } from '../common/interfaces/paginated-result.interface';

@Controller('users')
export class UsersController {
    constructor(private readonly usersService: UsersService) {}

    // Solo admins pueden crear usuarios directamente
    // (el registro público es vía /auth/register)
    @Post()
    @Roles(UserRole.ADMIN)
    create(@Body() createUserDto: CreateUserDto): Promise<User> {
        return this.usersService.create(createUserDto);
    }

    // Admins y editors pueden listar usuarios
    @Get()
    @Roles(UserRole.ADMIN, UserRole.EDITOR)
    findAll(@Query() filterDto: FilterUsersDto): Promise<PaginatedResult<User>> {
        return this.usersService.findAll(filterDto);
    }

    // Cualquier usuario autenticado puede ver su propio perfil
    @Get('me')
    getMe(@GetUser() user: JwtPayload): JwtPayload {
        return user;
    }

    // Solo admins pueden ver cualquier usuario por ID
    @Get(':id')
    @Roles(UserRole.ADMIN)
    findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
        return this.usersService.findOne(id);
    }

    // Admins y editors pueden actualizar, pero la lógica del service
    // debería verificar que un editor solo edite lo suyo
    @Patch(':id')
    @Roles(UserRole.ADMIN, UserRole.EDITOR)
    update(@Param('id', ParseUUIDPipe) id: string, @Body() updateUserDto: UpdateUserDto): Promise<User> {
        return this.usersService.update(id, updateUserDto);
    }

    // Solo admins borran usuarios
    @Delete(':id')
    @Roles(UserRole.ADMIN)
    remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
        return this.usersService.remove(id);
    }
}

Fíjate en que el método getMe() no tiene @Roles(). Eso significa que cualquier usuario autenticado puede acceder (el JwtAuthGuard global ya lo cubre). Solo los endpoints que necesitan restricción de rol llevan @Roles().


11. @Roles() a nivel de controller

Si todo un controller es solo para admins, pones @Roles() en la clase:

@Controller('admin/settings')
@Roles(UserRole.ADMIN)
export class AdminSettingsController {
    // TODOS los endpoints heredan @Roles(UserRole.ADMIN)

    @Get()
    getSettings(): Promise<Settings> {
        return this.settingsService.findAll();
    }

    @Patch()
    updateSettings(@Body() dto: UpdateSettingsDto): Promise<Settings> {
        return this.settingsService.update(dto);
    }

    // Override: solo superadmin (si tuvieras el rol) puede resetear
    @Delete('reset')
    @Roles(UserRole.ADMIN) // getAllAndOverride → usa este, no el del controller
    resetSettings(): Promise<void> {
        return this.settingsService.reset();
    }
}

Gracias a getAllAndOverride() en el Guard, si un método tiene su propio @Roles(), sobreescribe el del controller. Si no tiene, hereda el del controller.


12. Combinando @Public() con @Roles()

¿Qué pasa si un endpoint tiene @Public() y @Roles() a la vez? Depende del orden de los Guards:

1. JwtAuthGuard Ve @Public()  deja pasar SIN verificar JWT
2. RolesGuard Ve @Roles(UserRole.ADMIN)  intenta leer req.user req.user es undefined ERROR

Bueno aquí la cosa choca de forma lógica porque no puedes tener un endpoint público y a la vez restringido por rol. Si necesitas un endpoint que sea público pero que ofrezca más datos a usuarios autenticados, necesitas un enfoque diferente:

// Endpoint público con contenido extra para usuarios autenticados
@Get('posts')
@Public()
findPosts(@GetUser() user?: JwtPayload): Promise<Post[]> {
    // user será undefined si no hay token
    return this.postsService.findAll(user?.role);
}

Para esto, modifica el JwtAuthGuard para que en rutas @Public() intente verificar el token pero no falle si no hay:

// src/auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
    constructor(private readonly reflector: Reflector) {
        super();
    }

    canActivate(context: ExecutionContext): boolean | Promise<boolean> {
        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);

        if (isPublic) {
            return true;
        }

        return super.canActivate(context) as boolean | Promise<boolean>;
    }
}

Regla simple: @Public() = no requiere autenticación, por lo tanto no se puede combinar con @Roles(). Si necesitas roles, el usuario debe estar autenticado.


13. Guard de ownership: “solo puedo editar lo mío”

RBAC controla qué tipo de acciones puede hacer un rol. Pero a veces necesitas verificar que el recurso pertenece al usuario. Un editor puede editar posts, pero solo los suyos.

// src/auth/guards/ownership.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { type JwtPayload } from '../interfaces/jwt-payload.interface';
import { UserRole } from '../../users/enums/user-role.enum';

@Injectable()
export class OwnershipGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean {
        const request = context.switchToHttp().getRequest();
        const user = request.user as JwtPayload;
        const resourceUserId = request.params.id as string;

        // Los admins pueden acceder a todo
        if (user.role === UserRole.ADMIN) {
            return true;
        }

        // Los demás solo pueden acceder a su propio recurso
        if (user.sub !== resourceUserId) {
            throw new ForbiddenException('Solo puedes modificar tus propios recursos');
        }

        return true;
    }
}

Aplicación:

@Patch(':id')
@UseGuards(OwnershipGuard) // Se ejecuta DESPUÉS de JwtAuthGuard y RolesGuard
update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateUserDto: UpdateUserDto,
): Promise<User> {
    return this.usersService.update(id, updateUserDto);
}

Limitación: Este Guard básico solo funciona cuando el id del params es el ID del usuario. Para relaciones más complejas (como “este post pertenece a este usuario”), necesitas consultar la DB. Ahí si entra CASL (sección 16).


14. Composición de decoradores con applyDecorators

Si te cansa repetir la misma combinación de decoradores, crea uno compuesto:

// src/auth/decorators/admin-only.decorator.ts
import { applyDecorators, UseGuards } from '@nestjs/common';
import { Roles } from './roles.decorator';
import { UserRole } from '../../users/enums/user-role.enum';

export const AdminOnly = () => applyDecorators(Roles(UserRole.ADMIN));
// src/auth/decorators/owner-or-admin.decorator.ts
import { applyDecorators, UseGuards } from '@nestjs/common';
import { Roles } from './roles.decorator';
import { OwnershipGuard } from '../guards/ownership.guard';
import { UserRole } from '../../users/enums/user-role.enum';

export const OwnerOrAdmin = () =>
    applyDecorators(Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.USER), UseGuards(OwnershipGuard));

Uso:

@Delete(':id')
@AdminOnly()
remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
    return this.usersService.remove(id);
}

@Patch(':id')
@OwnerOrAdmin()
update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateUserDto: UpdateUserDto,
): Promise<User> {
    return this.usersService.update(id, updateUserDto);
}

Ahora si cambias la política de acceso, la cambias en un solo sitio e ya.


15. RBAC: Role-Based Access Control

Lo que hemos construido hasta ahora es . Vamos a repasar la arquitectura:

Usuario tiene un Rol el Rol tiene Permisos los Permisos permiten Acciones

Nuestro modelo actual:

AcciónUSEREDITORADMIN
Ver su propio perfil
Editar su propio perfil
Listar todos los usuarios
Crear usuarios
Editar cualquier usuario
Borrar usuarios

RBAC funciona bien cuando los permisos son por rol. Pero tiene un límite: ¿qué pasa cuando necesitas un editor puede editar sus propios posts pero no los de otros? Eso es un permiso por recurso, no por rol y para eso existe CASL.


16. CASL: permisos granulares

es la librería estándar para permisos granulares en Node.js que usamos cuando RBAC se queda corto.

16.1. Instalación

Instalación de CASL 0 / 1
$
Pulsa para ejecutar el siguiente comando

16.2. Definiendo acciones y subjects

// src/auth/casl/casl-action.enum.ts
export enum CaslAction {
    MANAGE = 'manage', // Wildcard: incluye todas las acciones
    CREATE = 'create',
    READ = 'read',
    UPDATE = 'update',
    DELETE = 'delete',
}
// src/auth/casl/casl-subject.type.ts
import { type User } from '../../users/entities/user.entity';
import { type Post } from '../../posts/entities/post.entity';

export type CaslSubjects = typeof User | typeof Post | User | Post | 'all'; // Wildcard: incluye todos los subjects

16.3. La fábrica de abilities

La ability factory centraliza TODA la política de permisos:

// src/auth/casl/casl-ability.factory.ts
import { AbilityBuilder, createMongoAbility, type MongoAbility, type InferSubjects } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { CaslAction } from './casl-action.enum';
import { UserRole } from '../../users/enums/user-role.enum';
import { User } from '../../users/entities/user.entity';
import { Post } from '../../posts/entities/post.entity';
import { type JwtPayload } from '../interfaces/jwt-payload.interface';

type Subjects = InferSubjects<typeof User | typeof Post> | 'all';
export type AppAbility = MongoAbility<[CaslAction, Subjects]>;

@Injectable()
export class CaslAbilityFactory {
    createForUser(user: JwtPayload): AppAbility {
        const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);

        switch (user.role) {
            case UserRole.ADMIN:
                // Admin puede hacer TODO con TODO
                can(CaslAction.MANAGE, 'all');
                break;

            case UserRole.EDITOR:
                // Editor puede leer todos los usuarios
                can(CaslAction.READ, User);
                // Editor puede crear y leer posts
                can(CaslAction.CREATE, Post);
                can(CaslAction.READ, Post);
                // Editor puede actualizar y borrar SOLO sus propios posts
                can(CaslAction.UPDATE, Post, { authorId: user.sub });
                can(CaslAction.DELETE, Post, { authorId: user.sub });
                // Editor puede actualizar su propio perfil
                can(CaslAction.UPDATE, User, { id: user.sub });
                break;

            case UserRole.USER:
                // User solo puede leer
                can(CaslAction.READ, User, { id: user.sub }); // Solo su perfil
                can(CaslAction.READ, Post);
                // User puede actualizar su propio perfil
                can(CaslAction.UPDATE, User, { id: user.sub });
                break;
        }

        return build();
    }
}

Mira esta línea:

can(CaslAction.UPDATE, Post, { authorId: user.sub });

Esto dice que puede actualizar un Post solo si post.authorId === user.sub. Es una condición a nivel de recurso que RBAC no puede expresar, pero CASL sí.

16.4. Registrando la fábrica

// src/auth/casl/casl.module.ts
import { Module } from '@nestjs/common';
import { CaslAbilityFactory } from './casl-ability.factory';

@Module({
    providers: [CaslAbilityFactory],
    exports: [CaslAbilityFactory],
})
export class CaslModule {}

Impórtalo en AuthModule:

// src/auth/auth.module.ts
@Module({
    imports: [UsersModule, PassportModule, JwtModule.register({}), CaslModule],
    // ...
})
export class AuthModule {}

16.5. Guard de policies con CASL

// src/auth/guards/policies.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory, type AppAbility } from '../casl/casl-ability.factory';
import { type JwtPayload } from '../interfaces/jwt-payload.interface';

export const CHECK_POLICIES_KEY = 'check_policies';

export type PolicyHandler = (ability: AppAbility) => boolean;

export const CheckPolicies = (...handlers: PolicyHandler[]) => SetMetadata(CHECK_POLICIES_KEY, handlers);

// Necesitamos importar SetMetadata arriba
import { SetMetadata } from '@nestjs/common';

@Injectable()
export class PoliciesGuard implements CanActivate {
    constructor(
        private readonly reflector: Reflector,
        private readonly caslAbilityFactory: CaslAbilityFactory
    ) {}

    canActivate(context: ExecutionContext): boolean {
        const policyHandlers = this.reflector.getAllAndOverride<PolicyHandler[]>(CHECK_POLICIES_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);

        if (!policyHandlers || policyHandlers.length === 0) {
            return true;
        }

        const request = context.switchToHttp().getRequest();
        const user = request.user as JwtPayload;
        const ability = this.caslAbilityFactory.createForUser(user);

        const allPoliciesMet = policyHandlers.every((handler) => handler(ability));

        if (!allPoliciesMet) {
            throw new ForbiddenException('No tienes permisos para esta acción');
        }

        return true;
    }
}

16.6. Usando policies en los controllers

import { CaslAction } from '../auth/casl/casl-action.enum';
import { CheckPolicies } from '../auth/guards/policies.guard';
import { PoliciesGuard } from '../auth/guards/policies.guard';
import { Post as PostEntity } from './entities/post.entity';

@Controller('posts')
@UseGuards(PoliciesGuard)
export class PostsController {
    // Cualquier autenticado puede leer posts
    @Get()
    @CheckPolicies((ability) => ability.can(CaslAction.READ, PostEntity))
    findAll(): Promise<PostEntity[]> {
        return this.postsService.findAll();
    }

    // Solo quien puede crear posts
    @Post()
    @CheckPolicies((ability) => ability.can(CaslAction.CREATE, PostEntity))
    create(@Body() createPostDto: CreatePostDto, @GetUser('sub') userId: string): Promise<PostEntity> {
        return this.postsService.create(createPostDto, userId);
    }

    // Solo el autor o un admin puede borrar
    @Delete(':id')
    @CheckPolicies((ability) => ability.can(CaslAction.DELETE, PostEntity))
    remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
        return this.postsService.remove(id);
    }
}

Importante: Las policies con condiciones ({ authorId: user.sub }) verifican la capacidad general del usuario. Para verificar que el recurso concreto cumple la condición, necesitas comprobar el recurso real en el service o con un interceptor que cargue la entidad y la adjunte a la request.


17. RBAC vs CASL: ¿cuándo usar cada uno?

Usa RBAC con @Roles()
  • Los permisos son por ROL, no por recurso individual
  • Reglas simples: "solo admins pueden borrar", "editors pueden editar"
  • No necesitas condiciones a nivel de campo o de propiedad del recurso
  • Tu aplicación tiene pocos roles y las reglas no cambian mucho
Usa CASL con policies
  • Necesitas "un usuario puede editar SOLO sus propios posts"
  • Los permisos dependen de propiedades del recurso concreto
  • Tienes reglas complejas que cambian dinámicamente
  • Quieres compartir la lógica de permisos entre backend y frontend

Mi consejo es que empieza con RBAC porque es más simple, más fácil de auditar y cubre el 80% de los casos, y añade CASL solo cuando RBAC se quede corto.


18. Errores comunes

Error 1: Guard que no ve req.user

// ❌ RolesGuard se ejecuta pero req.user es undefined
@Public()
@Roles(UserRole.ADMIN)  // Conflicto: @Public() salta JwtAuthGuard
@Delete(':id')
remove(): Promise<void> { ... }

Si el endpoint es @Public(), JwtAuthGuard no se ejecuta, no hay req.user y RolesGuard falla. No combines @Public() con @Roles().

Error 2: Olvidar registrar el Guard globalmente

// ❌ RolesGuard no hace nada si no está registrado
@Roles(UserRole.ADMIN)
@Delete(':id')
remove(): Promise<void> { ... }
// El decorador adjunta metadata pero nadie la lee

Si usas @Roles() sin registrar RolesGuard como APP_GUARD ni ponerlo con @UseGuards(), la metadata se adjunta pero nadie la lee. El endpoint queda desprotegido silenciosamente.

Error 3: Devolver false en vez de lanzar excepción

// ❌ Devuelve un 403 genérico sin contexto
canActivate(context: ExecutionContext): boolean {
    // ...
    return false; // El usuario no sabe POR QUÉ fue rechazado
}

// ✅ ForbiddenException con mensaje descriptivo
canActivate(context: ExecutionContext): boolean {
    // ...
    throw new ForbiddenException(
        `Se requiere rol: ${requiredRoles.join(', ')}`,
    );
}

Error 4: Poner lógica de negocio en el Guard

// ❌ El Guard no debería consultar la DB ni tener lógica de negocio
@Injectable()
export class PostOwnerGuard implements CanActivate {
    constructor(private readonly postsRepository: Repository<Post>) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const post = await this.postsRepository.findOne({ where: { id } });
        // Esto debería estar en el service, no en el guard
    }
}

Los Guards deciden basándose en metadata y datos de la request. La lógica que necesita consultar la DB es mejor manejarla en el service o con CASL + un interceptor que precargue el recurso.


19. Estructura del módulo Auth completa

Después de los posts 10 y 11, la estructura queda así:

src/
├── auth/
   ├── auth.module.ts
   ├── auth.service.ts
   ├── auth.controller.ts
   ├── casl/
   ├── casl.module.ts
   ├── casl-ability.factory.ts
   ├── casl-action.enum.ts
   └── casl-subject.type.ts
   ├── decorators/
   ├── get-user.decorator.ts Post 10
   ├── public.decorator.ts Post 10
   ├── roles.decorator.ts Post 11
   ├── admin-only.decorator.ts Post 11
   └── owner-or-admin.decorator.ts Post 11
   ├── dto/
   ├── login.dto.ts
   └── register.dto.ts
   ├── guards/
   ├── jwt-auth.guard.ts Post 10
   ├── jwt-refresh.guard.ts Post 10
   ├── local-auth.guard.ts Post 10
   ├── ownership.guard.ts Post 11
   ├── policies.guard.ts Post 11
   └── roles.guard.ts Post 11
   ├── interfaces/
   ├── auth-tokens.interface.ts
   └── jwt-payload.interface.ts
   └── strategies/
       ├── jwt.strategy.ts
       ├── jwt-refresh.strategy.ts
       └── local.strategy.ts
└── users/
    └── enums/
        └── user-role.enum.ts

20. Recapitulando

🛡️ Guards + CanActivate

Deciden si la request pasa o no. Se ejecutan después del middleware y antes de los pipes. Acceso al ExecutionContext.

🏷️ @Roles() + @SetMetadata

Decorador custom que adjunta metadata con los roles requeridos. El RolesGuard la lee con el Reflector.

🔍 Reflector

Helper para leer metadata. getAllAndOverride() prioriza handler sobre class. Conecta decoradores con Guards.

👥 RBAC

Control de acceso por roles. Simple, predecible. Cubre el 80% de los casos. @Roles(UserRole.ADMIN).

🔐 CASL

Permisos granulares. can(UPDATE, Post, { authorId: user.sub }). Para cuando RBAC se queda corto.

🧩 applyDecorators

Compone múltiples decoradores en uno. @AdminOnly() en vez de repetir @Roles() + @UseGuards() por todos lados.

En el próximo post blindaremos la API desde fuera usando Helmet para headers HTTP, CORS bien configurado, rate limiting con @nestjs/throttler y protección contra ataques comunes. La autenticación dice quién eres, la autorización dice qué puedes hacer, y la protección de la API evita que la revienten.

EA, nos vemos en los bares!! 🍺


Pon a prueba lo aprendido

1. ¿Cuál es la diferencia entre un 401 Unauthorized y un 403 Forbidden?

2. ¿Qué método del Reflector usamos en el RolesGuard y por qué?

3. ¿Qué pasa si un endpoint tiene @Public() y @Roles(UserRole.ADMIN) a la vez?

4. ¿Cuándo deberías usar CASL en vez de RBAC con @Roles()?

5. ¿Por qué el RolesGuard deja pasar las requests cuando no hay @Roles() en el endpoint?