⌘J
En esta página 6

Engineering / Multi-tenancy y aislamiento

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.

Tablas centrales (sin tenant_id)

users, tenants, tenant_user, y catálogos globales como clauses (derecho salvadoreño común a todos). Son cross-tenant por diseño.

Tablas de dominio (con tenant_id)

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:

  1. Global scope (Eloquent): el trait BelongsToTenant inyecta el tenant_id del 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.
  2. RLS de Postgres (backstop a nivel DB): policy fail-closed con FORCE y WITH CHECK, activa aunque alguien escriba un DB::table() crudo saltándose Eloquent.
  3. 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:

CREATE POLICY tenant_isolation ON contracts
USING (tenant_id = nullif(current_setting('app.current_tenant', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.current_tenant', true), '')::uuid);

El true (missing_ok) y el nullif son obligatorios. Sin ellos, current_setting lanza excepción con la variable ausente, o el cast de '' a uuid revienta. Con ambos, contexto ausente ⇒ predicado NULL ⇒ 0 filas, nunca un error 500. Ese es el fail-closed: contexto ausente falla vacío, no cruzado. El WITH CHECK además rechaza escribir una fila tagueada para otro tenant.

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.

Bajo el pooler transaccional (producción) la variable de sesión es un no-op deliberado: una GUC de sesión quedaría pegada a un backend compartido, que es fail-open. Ahí el único mecanismo es SET LOCAL: el trabajo de dominio va dentro de DB::transaction(), y lo no-transaccional queda en 0 filas (fail-closed).

Dos conexiones, dos roles

El aislamiento es estructural, no depende de que nadie se olvide de scopear.

ConexiónRol PostgresQuién la usa
pgsqlowner (ej. postgres)Migraciones, tablas centrales, y trabajo admin cross-tenant deliberado.
pgsql_tenantaxel (no-owner, sin BYPASSRLS)Todo el código de dominio. Físicamente no puede cruzar tenants.

Gotcha crítico: RLS es inerte para superusuarios y roles BYPASSRLS, y sin FORCE también para el owner de la tabla. Si la app conecta como owner/superuser, el aislamiento es teatro. Por eso el rol axel no es owner, la conexión runtime nunca toma credenciales de DB_URL, y la migración rechaza un rol con SUPERUSER/BYPASSRLS. Fuera de local/testing el rol lo provisiona ops (runbook: guides/provision-tenant-role en el wiki del repo).

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.

Middleware HTTP

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.

Jobs y fan-out

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:

#CasoCapa que pruebaAserción
1La conexión runtime reporta current_userprecondiciónes el rol no-owner
2Vía Eloquent + middleware, sesión = A, luego = Bglobal scope end-to-endcada request ve solo lo suyo
3withoutTenancy() (scope apagado), sesión = ARLS sostiene solove solo A
4Query crudo sin contextofail-closed0 filas, NO error 500
5INSERT tagueado para otro tenantWITH CHECKrechazado
6Lectura/escritura del tenant activohappy pathfunciona

El caso 4 es el que importa. Es el único que falla si alguien conecta como owner, se olvida de FORCE, o rompe el predicado. Si el caso 4 pasa, RLS está realmente vivo. Los demás pueden pasar en verde con RLS apagado.

Pendiente: correr contra Laravel Cloud real

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

F1: aislamiento por sesión ✓

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.

F2: RLS a nivel DB ✓

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.