Guardar los permissions efectivos en la sesión es una optimización razonable. Recalcular roles, grants y particiones en cada request sale caro, y casi siempre el resultado es el mismo. El problema es el “casi”. Una sesión materializada es una caché, y una caché sin estrategia de invalidación es un privilegio pegajoso esperando a ocurrir.
El payload suele llevar algo así:
user_id
roles
permissions
org_units
permission_version
authz_catalog_version
permissions_valid_untilLos tres últimos campos existen porque la sesión puede quedar obsoleta por tres motivos distintos, y cada uno necesita un mecanismo propio.
1. El usuario cambió
Ganó o perdió un role, cambió de unidad, recibió o perdió un grant directo. Un contador por usuario lo resuelve: toda mutación de autorización incrementa permission_version en la misma transacción del cambio. La sesión guarda la versión con la que se resolvió, y la diferencia entre las dos delata la caché vieja.
2. El catálogo cambió
Este es el caso que suele pasar desapercibido. Alguien añade un permission a un role, o lo quita, o cambia la semántica de un role comodín. Ningún usuario sufrió una mutación directa, así que ningún permission_version cambió, y todas las sesiones abiertas siguen con el conjunto antiguo. Un authz_catalog_version global, incrementado con cada cambio de catálogo, cubre exactamente ese hueco.
3. El reloj avanzó
Un grant temporal vence a las 22:00. Que el reloj pase de 21:59 a 22:00 no modifica ninguna fila en la base de datos. Si la sesión solo se invalida cuando cambia alguna versión, el privilegio vencido sigue activo hasta que alguien toque al usuario.
La tentación es resolverlo con un job que se ejecuta cada cinco minutos y revoca lo que ha vencido. Funciona hasta que el job se retrasa, falla o se apaga durante un mantenimiento, y entonces la ventana de privilegio vencido pasa a ser el intervalo entre ejecuciones, o infinita. La sesión tiene que llevar el próximo instante en que el conjunto materializado deja de ser confiable:
permissions_valid_until = min(expires_at de los grants directos activos)La expiración tiene que valer aunque no se ejecute ningún job.
El predicado de re-resolución
Con los tres campos, la decisión de recalcular es una comparación barata, hecha en cada request:
needs_reresolve(session, db) :=
session.permission_version != db.user_permission_version
OR session.authz_catalog_version != db.catalog_version
OR now() >= session.permissions_valid_untilCuando es verdadero, el servidor recalcula roles, permissions, particiones y el próximo vencimiento, actualiza la sesión y sigue con la request. Nadie tiene que cerrar sesión solo porque cambió la autorización. Las dos versiones caben en un lookup indexado, o en una caché invalidada por evento, y el reloj sale gratis.
Invalidar la autorización no es matar la sesión
Son problemas parecidos con soluciones distintas. Desactivar un usuario, resetear una credencial o responder a un incidente exige tumbar las sesiones activas, no solo recalcular lo que pueden hacer. Por eso un índice de sesiones por usuario, en Redis o equivalente, sigue siendo útil. El versionado responde a “qué puede hacer esta sesión ahora”; el session kill responde a “esta sesión no debería existir”.
El bootstrap del bypass
Un permission que atraviesa el RLS crea un problema del huevo y la gallina. Para saber si la conexión puede recibir bypass de tenant, hay que leer los permissions del usuario antes de abrir el ámbito de tenant. Así que lo mínimo necesario para resolver esa autoridad se lee en un ámbito de sistema estrecho y controlado, y solo después la aplicación decide si la conexión recibe un contexto común o un bypass explícito.
Contexto de conexión y pool
Cuando el RLS lee el tenant de una variable de sesión de Postgres, el ciclo de vida de esa variable forma parte de la seguridad. Con connection pooling, un SET normal sobrevive a la devolución de la conexión, y la siguiente request, de otro tenant, hereda el contexto anterior. Usa configuración local a la transacción:
SET LOCAL app.tenant_id = '...';
SET LOCAL app.is_platform_bypass = 'false';
-- o, desde dentro de una función:
SELECT set_config('app.tenant_id', '...', true);Y pruébalo de la forma que rompe: un test de integración que reutiliza a propósito la misma conexión entre dos tenants y demuestra que el tenant y el bypass se han reseteado. Es el tipo de bug que ningún test unitario detecta y que solo aparece en producción bajo carga.