erp-core/docs/01-fase-foundation/MGN-003-roles/especificaciones/ET-rbac-backend.md

32 KiB

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