RLS WITH CHECK — aislamiento de escritura cross-tenant (Hito D)
Resumen
Fecha: s101 (2026-06-01)
Autor: Edu + Claude Code Opus
Ámbito: Seguridad de base de datos · Multi-tenancy (Plan Hardening, Hito D)
Status: ✅ Cerrado (migración 0022, 5 tests nuevos, 42/42 RLS tests pasan)
Cierra GAP #1 del Plan Hardening post-Máster: el riesgo estructural de que un cliente escribiera datos en el espacio de otro a nivel de base de datos.
Lo que cambia
Hasta ahora:
- ✅ Lectura (
USING): aislada por fila — un cliente no ve datos de otro. - ❌ Escritura: 100% en código — sin respaldo de BD. En las 5 tablas con
organization_idnullable, elUSINGincluyeOR organization_id IS NULL(necesario para leer filas globales), y PostgreSQL reutilizaba esa expresión para validar escrituras → un cliente podía escribir una fila global (org=NULL) visible para todos.
Ahora (migración core/migrations/0022_rls_with_check.py):
- ✅ Lectura (
USING): idéntica, sin cambios → sigue siendo posible leer filas globales. - ✅ Escritura (
WITH CHECK): estricta:- Un cliente escribir solo filas de su propio tenant (
organization_id = <org activo>). - O en contexto bypass (
app.current_org_id IN ('0', '')→ superuser, seed, login anónimo). - Sin rama
IS NULL→ un cliente no puede fabricar filas globales.
- Un cliente escribir solo filas de su propio tenant (
Resultado: las filas globales (org=NULL) se crean siempre en bypass (seed, superuser, anonymous context), nunca desde un tenant con org activo → aislamiento total de escritura.
Diseño técnico
Las 33 políticas RLS afectadas
Se dividen en dos grupos:
Tablas con organization_id NOT NULL (28)
racks_rackgroup, racks_rack, blueprints_blueprint, blueprints_aiprompt,
device_profiles, custom_mibs, port_connections,
monitoring_monitoringtarget, monitoring_aiinsight, monitoring_slapolicy,
monitoring_notificationchannel, monitoring_escalationpolicy,
monitoring_incidentgroup, monitoring_maintenancewindow, monitoring_knownissue,
monitoring_runbook, monitoring_recurringpattern,
signage_signageplayer, signage_signageoperation, signage_mediaasset,
signage_playlist, signage_schedule, signage_clientproject,
signage_playbacklog, signage_contentdeployment, signage_clientsharelink,
terminal_script, terminal_agentinstance, core_storedcredential
→ USING = WITH CHECK (idéntico, no hay filas globales)
Tablas con organization_id NULL (5 — “globales”)
core_systemlog, core_scripttemplate,
racks_boxcategory, racks_stencil,
monitoring_monitoringalert
→ USING con OR org IS NULL (lecturas incluyen globales) | WITH CHECK sin OR org IS NULL (escrituras, no)
Expresiones SQL
_BYPASS = current_setting('app.current_org_id', true) IN ('0', '')
_OWN_ORG = organization_id = NULLIF(current_setting('app.current_org_id', true), '')::int
Política para tablas no-nullable:
CREATE POLICY tenant_isolation ON racks_rack
USING (_BYPASS OR _OWN_ORG)
WITH CHECK (_BYPASS OR _OWN_ORG);
Política para tablas nullable:
CREATE POLICY tenant_isolation ON racks_stencil
USING (_BYPASS OR organization_id IS NULL OR _OWN_ORG)
WITH CHECK (_BYPASS OR _OWN_ORG); -- SIN 'IS NULL'
Tests
5 tests nuevos en tests/test_rls.py, clase TestRLSWriteIsolation:
test_tenant_cannot_move_rack_to_other_tenant— bloquea UPDATE cross-tenanttest_tenant_can_update_own_rack— permite UPDATE same-tenant (control positivo)test_tenant_cannot_insert_credential_for_other_tenant— bloquea INSERT cross-tenanttest_tenant_cannot_make_stencil_global— bloquea UPDATEorg=NULLdesde un tenanttest_bypass_can_create_global_stencil— permite UPDATEorg=NULLen bypass (control positivo)
Ejecutados bajo rol no-superuser (rls_test_user) para que las políticas apliquen. Las violaciones lanzan ProgrammingError (SQLSTATE 42501 — RLS violation).
Verificado: todos los tests RLS pasan (42/42), ruff limpio.
Seguridad — verificación pre-deploy
Se verificó antes de escribir la migración que:
-
Las filas globales (
org=NULL) se crean exclusivamente en contexto bypass:- Durante seed (seed data, superuser context)
- Durante login anónimo (anónimo sin org activo)
- Nunca desde una request HTTP con
app.current_org_idseteado
-
Las escrituras legítimas no se rompen:
- Same-tenant UPDATEs pasan el CHECK (test 2)
- Bypass creación de globales pasa (test 5)
- Migraciones de datos dentro del sistema (bypass context) → sin problema
-
No hay fuga residual:
- Un tenant no puede crear / mover / copiar hacia otra org (tests 1, 3, 4)
- No hay rutas alternativas (búsqueda de bypass en el código: solo seed + superuser + anonymous login)
Aplicación en STAGE / PROD
python manage.py migrate core
Migración reversible (el reverse_sql restaura el estado 0019).
Qué ocurre:
- Solo redefine las 33 políticas PostgreSQL → no toca datos
- Sin downtime: las filas existentes no se modifican, solo cambian las reglas de acceso
- Las operaciones activas pueden continuar (la migración es una redefinición de política, no datos)
Ejecuta: Edu
Relación con el Plan Hardening
| Hito | GAP | Descripción | Status |
|---|---|---|---|
| A | #3 | Alerting PROD: scrape /metrics + vmalert + Alertmanager | ✅ s94 |
| B | #6 | Honestidad Signage: marcar vendors sin push | ✅ s96 |
| C | #4 | Portero API global: auth= en NinjaAPI | ✅ s99 |
| D | #1 | RLS de escritura: WITH CHECK en 33 políticas | ✅ s101 |
| E | #2 | Cifrado: CREDENTIAL_ENCRYPTION_KEY + re-cifrado | ⬜ |
| F | #5 | Auto-Plan: investigar validación | ⬜ |
Notas para la operación
- Reversión: si fuera necesario revertir (poco probable), la migración es reversible:
python manage.py migrate core 0021_add_remaining_indexes - Monitoreo post-deploy: vigilar que las operaciones normales sigan funcionando. Las únicas writes que cambien serán intentos de cross-tenant / fabricación de globales (que deberían ser 0 en operación normal).
Véase también
- [[entity—core—model—organization]]
- [[entity—core—model—systemlog]]
- [[entity—core—model—storedcredential]]
- [[concept—saas—multi-tenancy]]
- [[feature—core—portero-api-global]]