CreaRack-SL

Multi-tenancy con PostgreSQL Row Level Security

ADRactiveverificado 2026-04-25#adr#multi-tenancy#rls#postgres#security#saas

ADR — Multi-tenancy con PostgreSQL Row Level Security

Contexto

CreaRack Pro es un SaaS donde Organization es el tenant raíz. Cada User pertenece a una sola organización y los recursos principales (Rack, Blueprint, Stencil, MonitoringTarget, Playlist, Script, etc.) llevan FK organization_id. Antes de abril de 2026 el aislamiento descansaba únicamente en filtros ORM dentro de los servicios de cada app (.filter(organization=...)).

El modelo “filtro ORM por convención” funciona mientras toda escritura y lectura pase por servicios disciplinados. En la práctica, con ~509 endpoints, varias apps en paralelo y refactors frecuentes, el riesgo de un olvido (Rack.objects.all() en una vista mal revisada, una query crafted en un management command, una vista interna que escapa al patrón) era inaceptable para un SaaS B2B donde un único leak cruzaría datos de un cliente a otro.

Problema

Necesitamos una garantía de aislamiento que no dependa de que cada desarrollador escriba el filtro correcto. Debe ser:

  • Defensa en profundidad: si el filtro ORM falla u olvida aplicarse, el aislamiento sigue activo.
  • Compatible con el stack actual: Django 6 ASGI sobre Daphne, PgBouncer en transaction mode (ver decision--20260315--postgres-18-pgbouncer), CONN_MAX_AGE=0 (ver decision--20260201--conn-max-age-daphne).
  • Compatible con superuser/staff que necesitan ver datos cruzados (admin de soporte, dashboards globales).
  • Compatible con filas globales: Stencils del catálogo común, boxcategory, monitoring_monitoringalert con organization_id IS NULL.
  • Sin penalización significativa de rendimiento.

Opciones consideradas

1. Filtros ORM en services (status quo) — sigue siendo la primera línea, pero como única defensa es frágil. Cualquier query que se cuele sin filtro devuelve datos de otro tenant. Auditarlo manualmente en cada PR no escala. Descartada como única capa.

2. QuerySet manager por defecto con organization_id (TenantManager) — un manager que obliga a pasar la org en cada .objects(). Mejora la disciplina, pero sigue siendo Python: un .objects.all() o un .using() lo bypassea, y los management commands operan con request ausente. Tampoco protege escrituras directas vía bulk_update o SQL crudo. Descartada como única capa.

3. Schema-per-tenant (django-tenants / esquemas separados) — un schema PostgreSQL por organización. Aislamiento físico fuerte, pero migrations costosas (cada schema migra independientemente), backups complejos, cross-tenant analytics imposibles sin queries especiales, y Stencil / boxcategory globales requieren un schema “public” compartido con duplicación de lógica. Para un SaaS de cientos a miles de tenants supone una operación pesada. Descartada.

4. Aplicación-level filter en middleware (monkey-patch de QuerySet) — un middleware que parchea Manager.get_queryset() para inyectar el filtro. Resuelve el olvido, pero es invasivo, frágil ante upgrades de Django, complica testing y deja fuera SQL crudo. Descartada.

5. PostgreSQL Row Level Security (RLS) + SET LOCAL por request — el motor de base de datos aplica las políticas en cada SELECT/UPDATE/DELETE. Imposible bypassear desde Python sin permisos especiales. Compatible con PgBouncer transaction mode usando SET LOCAL (se auto-limpia al commit). Cruza el límite de defensa: el aislamiento ahora es responsabilidad del motor, no del lenguaje. Seleccionada.

Decisión

Aislamiento multi-tenant en dos capas:

  1. Capa primaria (rendimiento + claridad): filtros ORM .filter(organization=...) en services. Sigue siendo el patrón obligatorio en código nuevo.
  2. Capa de seguridad (red de protección): PostgreSQL RLS sobre las tablas con organization_id directo. TenantRLSMiddleware ejecuta SET LOCAL app.current_org_id = <id> dentro de una transacción atómica al inicio de cada request. Las políticas leen current_setting('app.current_org_id', true) y restringen filas. org_id = '0' es el bypass explícito para superusuarios y operaciones anónimas internas.

Implementado en 3f13e5a (2026-04-03) y consolidado en 88d5b5e + 44beaab (mismo día) tras detectar que aplicar RLS sobre core_user rompía la AuthenticationMiddleware de Django (la consulta a core_user ocurre antes de que se setee app.current_org_id). La fix definitiva en migration 0019_fix_rls_policies saca core_user de RLS y añade NULLIF para casteo seguro cuando la variable está vacía.

Cobertura actual: 29 tablas con organization_id requerido + 5 tablas con organization_id nullable (filas globales). Tablas hijas (Device → Rack, PortConnection → Device) heredan el aislamiento por integridad referencial: no necesitan política propia.

Consecuencias

  • Positivo: aislamiento garantizado por el motor. Una query sin filtro ORM ya no devuelve datos de otro tenant; devuelve cero filas. Auditoría de seguridad simplificada.
  • Positivo: compatible con PgBouncer transaction mode gracias a SET LOCAL. No filtra estado entre conexiones del pool.
  • Positivo: filas globales (organization_id IS NULL) siguen siendo visibles a todos los tenants sin lógica adicional.
  • Negativo · debugging: queries que deberían devolver filas devuelven vacío si app.current_org_id no está bien seteado. Requiere disciplina al diagnosticar: comprobar la variable antes de sospechar del filtro o de los datos.
  • Negativo · contextos sin request: management commands, scripts, jobs Huey y tests no pasan por el middleware. Hay que setear la variable manualmente o ejecutar como superusuario (org_id=0). Los tests usan fixtures dedicadas; los jobs Huey reciben organization_id por argumento.
  • Negativo · auth ordering: tablas leídas antes del middleware (típicamente core_user durante AuthenticationMiddleware) no pueden tener RLS. Documentado en 0019_fix_rls_policies. Cualquier nueva tabla consultada en el ciclo de auth queda fuera de RLS y depende del filtro ORM.
  • Negativo · superuser bypass: org_id=0 salta TODAS las políticas. Una vista accesible a superuser que filtra mal puede leakear; el riesgo se reduce porque solo staff de CreaRack tiene ese rol, pero no se elimina.
  • Negativo · coste de mantenimiento: cada tabla nueva con organization_id requiere actualizar la lista de la migration de RLS. Si se olvida, la tabla queda sin protección de motor (solo ORM). Mitigación: revisión obligatoria en PRs que añaden modelos con FK a Organization.

Status

Accepted. En producción desde 2026-04-03 (3f13e5a), fix crítico de auth en 88d5b5e el mismo día. Tests dedicados en tests/test_rls.py cubren aislamiento de Rack, Stencil global+tenant, monitoring y reset de middleware. Cualquier nueva tabla con FK a Organization debe incluirse en la lista de RLS o justificar exclusión (caso core_user).

Véase también

  • [[concept—saas—multi-tenancy]] — descripción funcional de las tres capas (filtro ORM + RLS + module gating)
  • [[entity—core—model—organization]] — tenant raíz
  • [[entity—core—model—user]] — FK a Organization, excluida de RLS por orden de auth middleware
  • [[concept—saas—module-gating]] — capa complementaria que controla acceso por plan
  • [[decision—20260201—conn-max-age-daphne]] — CONN_MAX_AGE=0 permite que SET LOCAL no filtre entre requests
  • [[decision—20260315—postgres-18-pgbouncer]] — PgBouncer transaction mode, compatible con SET LOCAL