Multi-tenancy y aislamiento
Cómo aísla platform los datos de cada tenant (shared schema + tenant_id + Postgres RLS), resolución por sesión, y el gotcha que hace o rompe el aislamiento.
Implementado en F2 (PR #199, 17-jul), con TenantIsolationTest como gate P0
en verde. Queda un ítem diferido al primer deploy: validar el comportamiento del
pooler transaccional de Laravel Cloud. El detalle fino vive en el wiki del repo
(architecture/tenant-isolation y el índice architecture/decisions); esta
página es el mapa.
Axel es multi-tenant: cada empresa piloto es un tenant con sus propios datos, y un query sin scope filtraría datos de otro tenant. El aislamiento cross-tenant es el riesgo #1 del producto (PII laboral real) y se defiende en tres capas que se refuerzan entre sí.
El modelo: shared schema + tenant_id + RLS
stancl/tenancy corre en modo single-database y configuración mínima (solo
aporta el modelo de tenant y el global scope): todos los tenants comparten una
base y un schema. Cada tabla de dominio lleva tenant_id; las tablas
verdaderamente globales no.
users, tenants, tenant_user, y catálogos globales como clauses
(derecho salvadoreño común a todos). Son cross-tenant por diseño.
contracts, positions, signing_jobs, etc. Llevan tenant_id, global
scope automático y RLS como backstop.
Las tres capas de defensa, de más blanda a más dura:
- Global scope (Eloquent): el trait
BelongsToTenantinyecta eltenant_iddel contexto en toda query de dominio y lo setea al crear. Es fail-open (sin contexto no filtra), por eso no puede ser la única capa. - RLS de Postgres (backstop a nivel DB): policy fail-closed con
FORCEyWITH CHECK, activa aunque alguien escriba unDB::table()crudo saltándose Eloquent. - Conexión runtime no-owner (aislamiento estructural, no disciplinario): las queries de dominio corren como un rol al que RLS sí aplica.
RLS fail-closed
Cada tabla de dominio se protege con una línea en su migración —
Rls::protect('contracts') — que aplica ENABLE + FORCE ROW LEVEL SECURITY,
la policy y los grants:
El contexto sigue el ciclo de vida de tenancy, no al middleware. Listeners de
TenancyInitialized/TenancyEnded setean y limpian la variable: cualquier forma
de entrar al contexto (request, Tenant::run(), un job) mueve el scope y RLS
juntos. Cada transacción re-estampa el contexto con SET LOCAL (transaccional ⇒
seguro bajo pooler), y una reconexión a mitad de request lo re-aplica sola.
Dos conexiones, dos roles
El aislamiento es estructural, no depende de que nadie se olvide de scopear.
| Conexión | Rol Postgres | Quién la usa |
|---|---|---|
pgsql | owner (ej. postgres) | Migraciones, tablas centrales, y trabajo admin cross-tenant deliberado. |
pgsql_tenant | axel (no-owner, sin BYPASSRLS) | Todo el código de dominio. Físicamente no puede cruzar tenants. |
Resolución por sesión
Dominio único, tenant por sesión (decisión 0.10, cerrada 16-jul). Se descartó
el subdominio por tenant: contradecía la elección de UUIDv7 (no revelar clientes) y
sumaba wildcard DNS + certificados por un beneficio estético. Ver
Decisiones → 0.10. Los subdominios se reservan para región,
no para tenant. Para referencias externas/API se usa public_id (ten_...,
patrón Stripe), nunca el UUID interno.
Todos los tenants viven bajo un solo host (app.axel.legal). El grupo de
middleware tenant hace dos pasos: EnsureUserBelongsToTenant re-verifica la
membresía contra tenant_user en cada request (miembro removido ⇒ pierde acceso
al instante, con redirect al selector, no un 403 muerto), y
InitializeTenantContext activa tenancy: los listeners hacen el resto.
Cada request opera con el contexto del tenant activo en sesión
(current_tenant_id, de F1). Las rutas centrales (login, selección) no llevan
el grupo tenant.
Fuera del ciclo HTTP el contexto entra por el mismo mecanismo:
$tenant->run(...) o tenancy()->initialize() disparan los listeners. Un
job nunca reusa el contexto de otro tenant.
TenantIsolationTest: el gate P0
El gate corre por la conexión runtime real (rol no-owner — si corriera como el superuser local, RLS sería inerte y el test teatro) y el grupo de middleware real. Casos:
| # | Caso | Capa que prueba | Aserción |
|---|---|---|---|
| 1 | La conexión runtime reporta current_user | precondición | es el rol no-owner |
| 2 | Vía Eloquent + middleware, sesión = A, luego = B | global scope end-to-end | cada request ve solo lo suyo |
| 3 | withoutTenancy() (scope apagado), sesión = A | RLS sostiene solo | ve solo A |
| 4 | Query crudo sin contexto | fail-closed | 0 filas, NO error 500 |
| 5 | INSERT tagueado para otro tenant | WITH CHECK | rechazado |
| 6 | Lectura/escritura del tenant activo | happy path | funciona |
Falta la mitad remota del gate: con el pooler transaccional activo, validar que
SET LOCAL sobrevive al pooling y que ninguna variable de sesión queda
pegada a un backend compartido (el modo de falla fail-open). También confirmar
que la Postgres gestionada permita provisionar el rol no-owner.
Estratificación por fase
Auth central: el staff se loguea, la identidad es cross-tenant, y el acceso a un tenant ajeno se rechaza. Sin RLS a nivel DB todavía.
El aislamiento bajó a la base: global scope + RLS fail-closed + conexión
no-owner, con TenantIsolationTest en verde. Los modelos de dominio llegan
en fases siguientes ya con el piso puesto.
Con shared schema la migración corre una sola vez (ya no hay riesgo de
“migraciones por schema ×N” ni tenants en versiones distintas). Esta decisión
(0.8) reemplazó a schema-per-tenant, entre otras razones porque elimina la clase
de problemas de search_path residual bajo pooling.