Fondos de plano compartidos entre organizaciones y borrado a ciegas (task #252)
Cuándo
Reparado el 2026-08-23 en el commit 5936c9b4 (PR #422, v1.82.4, task #252). La fecha de introducción del bug no está registrada: lleva presente desde que existe la subida de fondos de plano en blueprints/api/blueprints.py, sin fecha exacta conocida — se descubrió al auditar el disco de producción en la ronda del 23-08-2026.
Síntomas visibles
- Dos organizaciones distintas subiendo un fondo con el mismo nombre de fichero (p. ej. dos “plano.png”) se pisaban entre sí: la carpeta de subida era plana y el nombre en disco era literalmente el que traía el usuario.
- Reemplazar el fondo de un plano podía borrar del disco el fichero que otro plano — incluso de OTRA organización — seguía usando (
os.removesin comprobar referencias). En producción esto llegó a ocurrir de verdad: la organización de pruebas tenía en la mano el fondo del plano del CCIB. - 169 de los 171 ficheros de
uploads/blueprints(333 de los 335 MB) eran huérfanos: planos de planta de clientes que seguían en disco después de que el plano se hubiera “borrado” — ni el borrado definitivo ni un fallo de Auto-Plan liberaban el fichero. Con datos de clientes de por medio, esto es un problema de higiene con componente de privacidad, no solo de espacio en disco.
Causa raíz
Tres huecos independientes en el ciclo de vida del fondo de un Blueprint:
- Sin namespacing por organización:
update_blueprint_bgguardaba el fondo en una carpeta plana (uploads/blueprints/) con el nombre de fichero que trajera el navegador del usuario, saneado pero sin prefijo de organización — nombres repetidos entre tenants colisionaban. - Borrado ciego al reemplazar: al subir un fondo nuevo, el código borraba el fondo anterior (
os.remove(old_path)) sin comprobar si algún otroBlueprint—de la misma organización o de otra, vía restore de backup— seguía apuntando a ese mismo fichero. - Nadie liberaba el fichero al final del ciclo de vida: ni el borrado definitivo de un plano (
permanent_delete_blueprint,empty_blueprint_trash) ni el fallo de un import de Auto-Plan (que escribe la imagen en disco ANTES de encolar el análisis) borraban el fondo huérfano resultante.
Con RLS (Row Level Security) activo a nivel de base de datos, el aislamiento entre organizaciones estaba garantizado en las filas de la tabla blueprints_blueprint — pero el filesystem no tiene RLS: un os.remove sobre una ruta compartida es una fuga de aislamiento tan real como una consulta sin filtro de organización, y aquí no había ninguna barrera que lo impidiera.
Fix aplicado
Commit 5936c9b4b0218776753940703e8749e98ed1450b (PR #422):
- Carpeta por organización + nombre propio:
<org_id>/bp{id}_{slug}_{ts}{ext}en vez de un nombre plano — dos organizaciones nunca vuelven a colisionar. - Write-first: el fondo nuevo se escribe en disco ANTES de tocar el viejo — un plano nunca se queda sin fondo a medio camino de una subida.
_remove_bg_file_if_unreferenced(org, rel_path)(blueprints/api/blueprints.py): solo borra un fichero si (a) vive en el prefijo<org_id>/de la organización que está pidiendo el borrado — con RLS activo esta consulta no puede ver referencias de otras organizaciones, así que fuera del propio espacio NUNCA borra — y (b) ningún otroBlueprintde esa organización lo referencia todavía. Aplicada en reemplazo de fondo, borrado de fondo, borrado definitivo individual y vaciado de papelera._cleanup_failed_upload(blueprints/tasks.py): cuando un import de Auto-Plan falla, borra la imagen que quedó huérfana — esta comprobación sí mira todas las organizaciones porque la tarea Huey corre con bypass de RLS.- Comando de gestión
cleanup_blueprint_backgrounds(blueprints/management/commands/cleanup_blueprint_backgrounds.py): pasada única sobre los 333 MB ya acumulados en disco, con ensayo en seco por defecto (--applypara borrar de verdad). El código deja de generar huérfanos nuevos hacia delante; este comando es para la deuda ya acumulada y se ejecuta a mano, por decisión humana — no se ha lanzado todavía en producción.
Lecciones
- RLS protege la base de datos, no el filesystem: cualquier ruta de fichero derivada de datos de usuario y compartida entre tenants en una carpeta plana reabre el mismo problema de aislamiento que RLS ya resuelve en la BD.
- Un
os.removesobre un path de usuario sin reference-counting es peligroso siempre, y catastrófico en cuanto ese path puede pertenecer a otro tenant. - El código que “limpia hacia delante” no basta cuando ya hay deuda acumulada (333 MB en este caso) — hace falta una pasada de reconciliación explícita, separada del flujo normal y con ensayo en seco antes de borrar nada de verdad.
Preventivos futuros
- Ejecutar
cleanup_blueprint_backgrounds --applyen producción queda pendiente de decisión humana (Edu) — el ensayo en seco ya está disponible para dimensionar el impacto antes de lanzarlo. - Fleco aplazado a conciencia y documentado en la propia task #252: el dashboard sigue agrupando secciones por NOMBRE de plano en vez de por id (sin colisión posible hoy; el cambio toca caché de agregados + plantilla y se hará con su propio click-test).
Véase también
- [[entity—blueprints—model—blueprint]]
- [[concept—saas—multi-tenancy]]
- [[decision—20260403—multi-tenancy-rls]]
- [[entity—blueprints—service—autoplan]]
- [[entity—blueprints—service—run-autoplan-import]]