Volver a la wiki

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:

Ahora (migración core/migrations/0022_rls_with_check.py):

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:

  1. test_tenant_cannot_move_rack_to_other_tenant — bloquea UPDATE cross-tenant
  2. test_tenant_can_update_own_rack — permite UPDATE same-tenant (control positivo)
  3. test_tenant_cannot_insert_credential_for_other_tenant — bloquea INSERT cross-tenant
  4. test_tenant_cannot_make_stencil_global — bloquea UPDATE org=NULL desde un tenant
  5. test_bypass_can_create_global_stencil — permite UPDATE org=NULL en 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:

  1. 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_id seteado
  2. 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
  3. 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:

Ejecuta: Edu


Relación con el Plan Hardening

HitoGAPDescripciónStatus
A#3Alerting PROD: scrape /metrics + vmalert + Alertmanager✅ s94
B#6Honestidad Signage: marcar vendors sin push✅ s96
C#4Portero API global: auth= en NinjaAPI✅ s99
D#1RLS de escritura: WITH CHECK en 33 políticas✅ s101
E#2Cifrado: CREDENTIAL_ENCRYPTION_KEY + re-cifrado⬜
F#5Auto-Plan: investigar validación⬜

Notas para la operación


Véase también

Subir