CreaRack-SL

Incident: el borrado manual de una organización no dejaba traza ni limpiaba sus copias de seguridad

Resumen

La purga automática de organizaciones (90 días) escribía SystemLog y borraba MEDIA_ROOT/backups/<id>/ desde el fix del [[incident—20260903—huey-tasks-sin-rls-bypass]]. El botón manual “Permanent delete” del admin de Django nunca pasó por esa misma lógica: hacía eligible.delete() directamente sobre el queryset. Encontrado en PROD por una carpeta huérfana backups/19/ con 4 ZIP cifrados de una organización que ya no existe en base de datos, y 0 filas de SystemLog con action="organization.purge" pese a que el botón se había usado.

Severidad: MEDIA — no hay fuga de datos entre organizaciones ni pérdida de datos vivos; el fallo es de trazabilidad (no queda registro de quién borró qué) y de higiene de disco (copias cifradas abandonadas para siempre).

Estado: corregido en el mismo commit que lo detectó — sin incidente abierto en producción ni remediación pendiente.

Cuándo

Detectado y corregido el 2026-09-06 (task #292), commit de67696224cf7b790d8f01fe70fad7eab45eaf4c (PR #510, v1.117.0).

Síntomas visibles

  • Carpeta MEDIA_ROOT/backups/19/ en PROD con 4 ZIP cifrados de una organización que ya no existía en la base de datos.
  • 0 filas en SystemLog con action="organization.purge" pese a que el botón “Permanent delete” del admin se había usado alguna vez.

Causa raíz

OrganizationAdmin.action_permanent_delete (core/admin.py) llamaba eligible.delete() directamente sobre el queryset. La purga automática de 90 días (core/tasks.py::purge_deleted_organizations) sí pasaba por _purge_one_org — la función que escribe el SystemLog y borra el directorio de backups —, pero el botón manual del admin nunca se conectó a esa misma lógica. Los dos caminos hacían la misma acción destructiva (borrar una organización para siempre) por rutas de código completamente distintas, y solo uno cumplía el contrato de trazabilidad y limpieza.

Fix aplicado

Commit de67696224cf7b790d8f01fe70fad7eab45eaf4c (PR #510, v1.117.0):

  • _purge_one_org sale de core/tasks.py y se convierte en purge_organization(org, actor=None) en el nuevo core/services/org_purge.py — punto único de verdad para los dos caminos.
  • actor queda en la traza de SystemLog (by=<email> en el borrado manual, by=scheduled purge (90d) en el automático).
  • El camino manual pasa a ejecutarse bajo rls_bypass(), igual que el automático.
  • 4 tests nuevos en tests/api/test_ronda_0609_purga_org.py; los 3 que cubren el defecto se comprobaron en rojo antes del fix, incluido uno que verifica que ambos caminos escriben la misma acción de auditoría.

Lecciones

  • Cuando dos caminos de código distintos ejecutan la MISMA acción destructiva, deben compartir la misma función — no solo el mismo objetivo. Arreglar uno no arregla el otro si siguen siendo implementaciones separadas.
  • Un botón de admin de Django es tan código de producción como una tarea programada: merece el mismo contrato de trazabilidad y limpieza.

Preventivos futuros

Cualquier acción destructiva irreversible nueva (borrado permanente, purga, CASCADE) que pueda dispararse desde más de un sitio — panel de admin, tarea periódica, comando de gestión — se implementa UNA vez en core/services/ y se llama desde todos los sitios, nunca se reimplementa. Mismo patrón ya aplicado en [[entity—core—service—backup-service]].

Véase también

  • [[incident—20260903—huey-tasks-sin-rls-bypass]]
  • [[entity—core—model—organization]]
  • [[entity—core—service—backup-service]]
  • [[entity—core—model—systemlog]]
  • [[decision—20260403—multi-tenancy-rls]]