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
SystemLogconaction="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_orgsale decore/tasks.pyy se convierte enpurge_organization(org, actor=None)en el nuevocore/services/org_purge.py— punto único de verdad para los dos caminos.actorqueda en la traza deSystemLog(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]]