Multi-tenant RBAC — Patrones de diseño
¿Qué es esto?
Una guía de patrones y anti-patrones para diseñar sistemas de permisos en aplicaciones SaaS multi-tenant. Basado en auditorías reales de arquitecturas SaaS que fallaron en producción.
Principio fundamental
Un sistema de permisos SaaS se diseña para el CASO LÍMITE, no para el caso feliz.
El caso feliz es "un admin configura permisos y todo funciona". El caso límite es "una cuenta temporal de inspector no se revocó y filtró datos de 3 ayuntamientos".
Los 7 anti-patrones que matan SaaS
1. 🔴 Roles fijos en CHECK constraint
-- ❌ ESTO MATA
rol VARCHAR(50) NOT NULL CHECK (rol IN (
'superadmin', 'admin', 'directivo', 'jefe_dept', 'operario', 'ciudadano'
));
Problema: Cada organización tiene estructura distinta. Un ayuntamiento necesita inspector_medioambiental, otro necesita conductor, otro supervisor_rutas. Roles fijos = forzar a todos los clientes al mismo molde.
Solución: Tabla por tenant:
-- ✅ CORRECTO
CREATE TABLE roles_organizacion (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organizaciones(id),
nombre VARCHAR(100) NOT NULL,
nivel INTEGER NOT NULL DEFAULT 0, -- 0=admin, 50=operario, 99=lectura
hereda_de UUID REFERENCES roles_organizacion(id),
es_admin BOOLEAN DEFAULT false,
UNIQUE (org_id, nombre)
);
2. 🔴 Sin RLS en PostgreSQL
Problema: Todo el control de acceso está en middleware (Node.js, Python, etc.). Si hay un bug en una ruta API, se exponen datos de TODOS los tenants. Defensa en profundidad = 0.
Solución: RLS siempre activo:
-- Función helper
CREATE OR REPLACE FUNCTION app_org_id() RETURNS UUID AS $$
SELECT NULLIF(current_setting('app.org_id', true), '')::UUID;
$$ LANGUAGE SQL STABLE;
-- RLS en cada tabla
ALTER TABLE activos ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON activos
FOR ALL USING (org_id = app_org_id());
-- El middleware inyecta el contexto
await pool.query(`SELECT set_config('app.org_id', $1, true)`, [req.user.org_id]);
3. 🔴 Sin herencia de permisos
Problema: Cada usuario/rol necesita permisos explícitos. Con 10 recursos × 4 acciones × 5 roles = 200 combinaciones. Si un operario necesita un permiso nuevo, se lo pones a él pero NO a su jefe.
Solución: Herencia de roles via FK recursiva:
superadmin (nivel 0) → hereda de NADIE
admin (nivel 10) → hereda de directivo
directivo (nivel 20) → hereda de jefe_dept
jefe_dept (nivel 30) → hereda de operario
operario (nivel 50) → hereda de ciudadano
ciudadano (nivel 99) → hereda de NADIE
Al consultar permisos, usar CTE recursivo para recorrer la cadena de herencia. Solo configuras permisos para el nodo base y excepciones.
4. 🔴 Sin distinción VER vs EDITAR
Problema: Si un rol puede editar un recurso, también puede verlo. Un alcalde (directivo) necesita VER salarios pero NO editarlos. El modelo actual no lo permite.
Solución: 4 niveles jerárquicos:
NONE (0) → No ve el recurso
READ (1) → Solo GET, ni PUT ni POST ni DELETE
WRITE (2) → GET + POST + PUT (crear y editar)
ADMIN (3) → Todo + puede conceder permisos a otros
Cada nivel IMPLICA los inferiores: ADMIN tiene WRITE + READ. El middleware verifica: nivel_requerido <= nivel_tiene.
5. 🔴 Optimistic locking sin release automático
Problema: Usuario A abre un activo → version++. Usuario A cierra el navegador. Nadie más puede editar ese activo NUNCA. En un equipo de 20 personas, en 1 semana están todos los activos bloqueados.
Solución: Lock + timeout + cron:
-- Añadir columnas de lock
ALTER TABLE activos ADD COLUMN locked_by UUID REFERENCES usuarios(id);
ALTER TABLE activos ADD COLUMN locked_at TIMESTAMPTZ;
-- Cron que libera locks stale (cada 5 min)
CREATE OR REPLACE FUNCTION release_stale_locks() RETURNS INTEGER AS $$
DECLARE
released INTEGER;
BEGIN
UPDATE activos SET locked_by = NULL, locked_at = NULL
WHERE locked_at IS NOT NULL
AND locked_at < now() - INTERVAL '10 minutes'
AND deleted_at IS NULL;
GET DIAGNOSTICS released = ROW_COUNT;
RETURN released;
END;
$$ LANGUAGE plpgsql;
6. 🟡 Sin admin override auditable
Problema: Cuando hay que saltarse la norma (ej: mover un activo en mantenimiento para una emergencia), el admin no tiene mecanismo. Acaba haciendo SQL directo en producción (¡peligro!).
Solución: Flag de bypass + auditoría:
ALTER TABLE usuarios ADD COLUMN bypass_security BOOLEAN DEFAULT false;
El middleware verifica: si bypass_security = true, salta los checks de permisos, pero el audit_log incluye flag: 'BYPASS'. El superadmin puede conceder bypass solo temporalmente (con expiración).
7. 🟡 Permisos con VARCHAR mágico sin estructura
-- ❌ ESTO ROMPE SILENCIOSAMENTE
alcance VARCHAR(100) -- 'dept:policia', 'zona:centro', 'global'
Un typo 'dept:polica' vs 'dept:policia' crea un permiso que nunca funciona. Sin error, sin alerta, sin logs.
Solución: Estructurar el alcance:
alcance_tipo VARCHAR(30) NOT NULL CHECK (alcance_tipo IN (
'global', 'departamento', 'zona', 'propio'
)),
alcance_valor VARCHAR(100), -- 'policia', 'centro', NULL si global
Patrones que SÍ funcionan
Grupos de usuarios
Los permisos se asignan a grupos, no a usuarios. Un usuario hereda de TODOS sus grupos.
CREATE TABLE grupos (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organizaciones(id),
nombre VARCHAR(100) NOT NULL,
UNIQUE (org_id, nombre)
);
CREATE TABLE grupo_usuarios (
grupo_id UUID NOT NULL REFERENCES grupos(id),
usuario_id UUID NOT NULL REFERENCES usuarios(id),
PRIMARY KEY (grupo_id, usuario_id)
);
-- Los permisos se asignan a grupos o a usuarios (caso excepcional)
CREATE TABLE permisos_asignacion (
id UUID PRIMARY KEY,
org_id UUID NOT NULL,
recurso_categoria VARCHAR(100) NOT NULL,
permiso_nivel VARCHAR(20) NOT NULL,
alcance_tipo VARCHAR(30) NOT NULL,
alcance_valor VARCHAR(100),
grupo_id UUID REFERENCES grupos(id),
usuario_id UUID REFERENCES usuarios(id),
granted_by UUID NOT NULL,
expira_en TIMESTAMPTZ,
CHECK (
(grupo_id IS NOT NULL AND usuario_id IS NULL)
OR (grupo_id IS NULL AND usuario_id IS NOT NULL)
)
);
Permisos temporales
El 80% de las filtraciones de datos son cuentas temporales nunca revocadas.
permisos_asignacion.expira_en TIMESTAMPTZ -- NULL = permanente
El middleware verifica en cada request:
WHERE (expira_en IS NULL OR expira_en > now())
Cron diario que notifica al admin_org sobre permisos próximos a expirar y permisos ya expirados.
Rate limiting por acción de usuario
Un operario no debería poder mover 10.000 activos en 1 minuto.
- Por rol: operario = 100 acc/hora, admin = 1000 acc/hora
- Implementación: Redis + SLIDING WINDOW
- Respuesta: HTTP 429 con
Retry-Afterheader - Configurable por tenant (algunos necesitan más)
Nuevos recursos data-driven
Cuando se añade un módulo (ej: "drones"), no debe requerir release de software:
- El módulo registra sus tipos de recurso en una tabla
asset_types - Los permisos ya aceptan cualquier
recurso_categoria - El admin configura permisos desde UI sin código nuevo
Arquitectura de verificación (3 capas)
Capa 1: RLS en PostgreSQL (defensa última)
└─ Filtra por org_id automáticamente
Capa 2: Middleware de autorización
└─ Verifica: ¿tiene permiso para esta acción en este recurso?
└─ 4 niveles + herencia + grupos + permisos temporales
Capa 3: Bypass flag + auditoría
└─ Admin puede overridear pero queda registrado
Las 3 capas son independientes. Si falla la Capa 2, la Capa 1 sigue protegiendo. Si falla la Capa 1, la Capa 2 sigue protegiendo.
Checklist para auditar un sistema de permisos
- ¿Los roles son configurables por tenant? ¿O hay CHECK fijo?
- ¿Hay RLS en PostgreSQL (o equivalente en BD)?
- ¿Los roles heredan permisos? ¿O cada rol necesita config explícita?
- ¿Hay distinción READ vs WRITE vs ADMIN?
- ¿Los optimistic locks tienen release automático con timeout?
- ¿Hay admin override auditable (bypass flag)?
- ¿Los alcances de permisos están estructurados? ¿O son VARCHAR mágico?
- ¿Hay grupos de usuarios?
- ¿Hay permisos temporales con expiración?
- ¿Hay rate limiting por usuario/rol?
- ¿Los cambios en permisos se auditan?
- ¿Nuevos tipos de recurso se pueden añadir sin código?
Referencias
terral-architecture— Caso real de aplicación de estos patrones en TerrAn (ERP municipal SaaS)terral-architecture/references/audit-42-issues.md— 14 issues de permisos encontrados en auditoría real