Inicio Expertise Proyectos Soluciones CV Stack Blog Contacto
Volver al blog
SaaS 8 de mayo de 2026 10 min lectura

RBAC granular en un SaaS
multi-tenant con NestJS

Roles y permisos no son lo mismo. Un sistema con solo roles te obliga a crear un rol nuevo por cada variación de acceso. RBAC granular — permisos a nivel de acción, asignables por rol — te da la flexibilidad que los SaaS reales necesitan. Acá cuento cómo lo implementé con NestJS, Prisma y PostgreSQL RLS en producción.

NestJS RBAC Prisma PostgreSQL Multi-tenant
Matriz de permisos RBAC para SaaS multi-tenant con roles y llaves de acceso
superAdmin admin manager agent users:manage trunks:read cdr:read supervision:listen calls:own PostgreSQL RLS — tenant_id isolation (DB level)

El problema de los sistemas basados solo en roles

Cuando un sistema de autenticación tiene solo roles (admin, manager, agent), cada variación de acceso requiere un nuevo rol o lógica hardcoded en el controller. "El manager puede ver CDR pero no puede eliminar usuarios. El admin puede eliminar usuarios pero no puede escuchar llamadas. El supervisor puede escuchar pero no puede tomar llamadas." Y así infinito.

RBAC granular resuelve esto con una capa adicional: permisos como entidades de primera clase. Los roles son contenedores de permisos. Un permiso es una acción específica (users:manage, cdr:read, supervision:listen). Los guards evalúan permisos, no roles.

La arquitectura de guards en NestJS

El decorator @Permissions()

El punto de entrada es un decorator que anota los endpoints con los permisos requeridos:

// permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const Permissions = (...perms: string[]) =>
  SetMetadata(PERMISSIONS_KEY, perms);

En el controller:

@Get('users')
@UseGuards(JwtAuthGuard, RolesGuard)
@Permissions('users:read')
findAll(@TenantUser() user: AuthUser) {
  return this.usersService.findAll(user.tenantId);
}

El RolesGuard

El guard extrae los permisos requeridos y los compara contra el set de permisos del usuario autenticado:

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

  canActivate(ctx: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<string[]>(
      PERMISSIONS_KEY,
      [ctx.getHandler(), ctx.getClass()]
    );
    if (!required?.length) return true;

    const { user } = ctx.switchToHttp().getRequest();

    // superAdmin bypassa TODOS los permission checks
    if (user.isSuperAdmin === true || user.role === 'superAdmin') {
      return true;
    }

    return required.every(p => user.permissions?.includes(p));
  }
}
El bypass de superAdmin

El superAdmin es el único actor que trasciende los tenants. Debe bypassar todos los guards de permisos, pero el check debe ser explícito y verificar ambas condiciones (isSuperAdmin del JWT y role === 'superAdmin') para evitar privilege escalation por manipulación del token.

Permisos programáticos en acciones compuestas

Algunos endpoints tienen lógica de acceso que no se puede expresar con un solo permiso en el decorator. Por ejemplo, en el módulo de supervisión: cualquier manager puede ver el wallboard, pero solo quienes tienen supervision:listen pueden escuchar en vivo, y solo quienes tienen supervision:takeover pueden tomar la llamada.

@Post('listen/:extensionId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Permissions('supervision:listen')  // guard base
async listenCall(
  @TenantUser() user: AuthUser,
  @Param('extensionId') extensionId: string,
) {
  // check granular adicional si necesita lógica
  if (!user.permissions.includes('supervision:listen')) {
    throw new ForbiddenException('Sin permiso para escuchar llamadas');
  }

  const ext = await this.extensionsService.findByUser(user.userId);
  if (!ext) throw new ForbiddenException('Usuario sin extensión asignada');

  const activeCall = await this.callsService.getActiveCall(extensionId);
  if (!activeCall) throw new ConflictException('No hay llamada activa');

  return this.supervisionService.listen(ext.number, extensionId, user);
}

Aislamiento multi-tenant con Row Level Security

Los guards en la aplicación son la primera capa de aislamiento. Pero en un SaaS multi-tenant, necesitás una segunda capa en la base de datos: Row Level Security (RLS) en PostgreSQL.

RLS garantiza que aunque haya un bug en el código de la aplicación, un usuario de un tenant nunca pueda ver datos de otro tenant. La DB misma lo bloquea.

-- Habilitar RLS en las tablas principales
ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Extension" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "CDR" ENABLE ROW LEVEL SECURITY;

-- Policy: solo ves filas de tu tenant
CREATE POLICY tenant_isolation ON "User"
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- En cada query, Prisma setea el contexto:
-- SET app.tenant_id = '...'

Con Prisma, esto se implementa como middleware en el cliente:

prisma.$use(async (params, next) => {
  const tenantId = AsyncLocalStorage.getStore()?.tenantId;
  if (tenantId) {
    await prisma.$executeRaw`
      SET LOCAL app.tenant_id = ${tenantId}
    `;
  }
  return next(params);
});
SET LOCAL vs SET

SET LOCAL aplica el valor solo para la transacción actual. SET lo aplica para toda la sesión. En un pool de conexiones como el de Prisma, usar SET contamina la sesión para queries subsiguientes de otros tenants. Siempre SET LOCAL dentro de una transacción.

El bug de RolesGuard con superAdmin

Durante la implementación encontré un bug clásico: el superAdmin empezó a recibir 403 en algunos endpoints. El problema era que el RolesGuard original solo evaluaba user.role === 'admin' como bypass, no contemplaba superAdmin.

El fix fue agregar el bypass explícito al inicio del guard:

// ❌ Antes — solo bypassaba admin local
if (user.role === 'admin') return true;

// ✅ Después — bypass correcto para superAdmin cross-tenant
if (user.isSuperAdmin === true || user.role === 'superAdmin') {
  return true;
}

La lección: el superAdmin es un actor distinto al admin de un tenant. Si tratás al superAdmin como un admin con más permisos, vas a tener bugs de privilege escalation o de acceso denegado. El bypass debe ser el primero en ejecutarse, antes de cualquier evaluación de permiso.

El sistema de licencias como capa ortogonal

Una pieza que completa el puzzle es el control de licencias: un tenant no puede crear más agentes de los que pagó. Esto NO es un permiso RBAC: es una constraint de negocio. Se implementa como un guard separado:

@Injectable()
export class LicenseGuard implements CanActivate {
  async canActivate(ctx: ExecutionContext): Promise {
    const { user, body } = ctx.switchToHttp().getRequest();

    // superAdmin nunca está limitado por licencias
    if (user.isSuperAdmin) return true;

    const license = await this.licenseService.getForTenant(user.tenantId);

    // purchased=0 = ilimitado
    if (license.purchased === 0) return true;

    if (body.role === 'agent' && license.used >= license.purchased) {
      throw new ForbiddenException(
        `Límite de licencias alcanzado (${license.used}/${license.purchased})`
      );
    }
    return true;
  }
}

Resumen de capas

Un SaaS multi-tenant bien implementado tiene 3 capas de autorización independientes: RBAC granular (quién puede hacer qué acción), RLS en DB (qué datos puede ver, independientemente del código), y License Guard (cuánto puede usar, limitado por el plan contratado). Cada capa falla de forma independiente y no depende de las otras.