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(verdecision--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_monitoringalertconorganization_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:
- Capa primaria (rendimiento + claridad): filtros ORM
.filter(organization=...)en services. Sigue siendo el patrón obligatorio en código nuevo. - Capa de seguridad (red de protección): PostgreSQL RLS sobre las tablas con
organization_iddirecto.TenantRLSMiddlewareejecutaSET LOCAL app.current_org_id = <id>dentro de una transacción atómica al inicio de cada request. Las políticas leencurrent_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_idno 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 recibenorganization_idpor argumento. - Negativo · auth ordering: tablas leídas antes del middleware (típicamente
core_userduranteAuthenticationMiddleware) no pueden tener RLS. Documentado en0019_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=0salta 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_idrequiere 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 aOrganization.
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=0permite queSET LOCALno filtre entre requests - [[decision—20260315—postgres-18-pgbouncer]] — PgBouncer transaction mode, compatible con
SET LOCAL
Referenciado desde
- ¿Cómo está implementado el aislamiento multi-tenant en CreaRack Pro? ¿Se usa django-tenants con schemas de PostgreSQL o un modelo de organización por fila (Organization FK)? ¿Qué mecanismos garantizan
- Auditoria 2026-07 Fase 1: Seguridad, Rendimiento y Calidad
- blueprints.services.uploads — validación compartida de subida de imágenes
- DeviceTrashEntry · Modelo network
- Endpoint POST /api/correo/ask · Chat de la Secretaria (F1)
- Endpoints /api/correo/peticiones · Cola de Encargos de Correo (F2)
- Fondos de plano compartidos entre organizaciones y borrado a ciegas (task #252)
- Fuga cross-tenant en el publicador anónimo de signage (playlist_id sin validar)
- Incident: el borrado manual de una organización no dejaba traza ni limpiaba sus copias de seguridad
- Incident: RLS-gap en 3 tablas nuevas (AIInsight, AIInsightAuditLog, VictoriaMetrics ingest)
- Incident: tareas Huey (purga, integridad, monitor de conexiones) corrían sin RLS — inertes en PROD
- Ingest Bibliotecario en 3 capas: pre-LLM + Haiku + Sonnet caching
- Lint Bibliotecario en 2 capas: bulk sin LLM + contradictions con Haiku
- Menú Correo del Workspace · F0/F1/F2 — Chat y Cola de Peticiones
- Modelo RackIntegritySnapshot — foto diaria de integridad plano↔realidad
- Motor de Integridad F1 — cruce plano↔realidad con métricas separadas
- Retirar el camino cloud de monitorización (batch-ping/snmp/http) y las tablas MetricSample/AggregatedMetric
- Servidor MCP de CreaRack: asistentes de IA consultan WiFi y estado de equipos con la cuenta del usuario (entrega 1, v1.173.0)
- Servidor MCP de CreaRack: pantallas de conexiones y actividad (entrega 2)
- Sistema Supercontexto · wiki auto-mantenida con grafo D1