Volver a la wiki

Multi-tenancy con PostgreSQL Row Level Security

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:

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

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

Subir