fix(umind): auditoría de las cinco features — cinco defectos y un guard permanente

Revisión de coherencia de todo lo que entró. Cinco cosas mal, ninguna
detectable compilando:

1. LA PUERTA DE TOOLS DECIDÍA AL REVÉS. executeUmindTool encolaba antes de
   clasificar, así que en un canal público CUALQUIER nombre de herramienta
   terminaba como pendiente de aprobación — incluidos los que el modelo
   inventa y los internos, cuyos guards ("solo el dueño") quedaban
   inalcanzables porque vivían más abajo en el flujo. Un visitante del widget
   podía llenarle al dueño la bandeja de solicitudes para ejecutar cosas que
   no existen.

   Ahora se clasifica primero: lectura ejecuta, interna se rechaza si el
   pedido es público, acción se encola, desconocida se rechaza. Verificado
   inyectando la falla: el test falla y vuelve a pasar al arreglarlo.

2. El enlace "Ver el documento" de la bandeja estaba muerto: archivo_id no lo
   escribía nadie. Ahora la acción aprobada engancha el PDF que produjo.

3. Los PDF generados esquivaban la cuota de almacenamiento. El tope del plan
   se evitaba pidiéndole documentos al agente en vez de subiendo archivos.

4. La vista de Consumo no conocía el tipo "documento" — el rubro nuevo salía
   sin nombre ni color. Y el icono que le puse no existía en el set, así que
   habría quedado invisible: el mismo bug de los iconos de hace unas semanas,
   que compila y se ve vacío.

5. Las seis lecturas nuevas se registraron con w() en vez de r(), exigiendo
   SoloAdmin para leer. No era una fuga —era más estricto— pero un usuario
   staff sin admin veía la lista de agentes y 403 en sus avisos.

Y el guard que evita el próximo: TestTodoHandlerUmindValidaAlcance recorre los
65 handlers de uMind y exige que cada uno valide el alcance del tenant, o esté
en una lista de exenciones con el motivo escrito. Los mismos handlers se montan
para staff y para clientes; el middleware de scope deja los tenants permitidos
en el contexto pero no mira el :id de la URL — eso lo hace cada handler, y el
que se olvide compila igual. Al escribirlo el propio test encontró que mi lista
de exenciones sobraba en dos, y verifiqué con un handler con fuga deliberada
que lo detecta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 22:04:03 -05:00
co-authored by Claude Opus 5
parent 706263db92
commit b7c52f174a
11 changed files with 244 additions and 10 deletions
+93
View File
@@ -0,0 +1,93 @@
package controllers
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Los mismos handlers de uMind se montan bajo /app (staff) y bajo /portal
// (clientes). El middleware de scope deja la lista de tenants permitidos en el
// contexto, pero NO mira el :id que llega en la URL — eso lo tiene que hacer
// cada handler. Un handler nuevo que se olvide del chequeo es una fuga entre
// clientes, y compila igual.
//
// Este test no prueba comportamiento: prueba que nadie agregue el próximo
// handler sin el chequeo. Es lo único que escala a 40 handlers.
var helpersDeAlcance = []string{
"accesoTenant(", "accesoAgente(", "accesoDocumento(", "accesoConexion(",
"accesoAiConfigPropia(", "accesoCanal(", "accesoHerramienta(",
"ConAcceso(c)", "UmindScopeDe(c)",
}
// sinAlcanceJustificado son los handlers que legítimamente no filtran por
// tenant, cada uno con el motivo por el que no lo necesita.
var sinAlcanceJustificado = map[string]string{
"UmindIndex": "renderiza la página del SPA, sin datos",
"UmindPlanesPage": "renderiza la página de planes, sin datos",
"GetPlantillasRubroHandler": "catálogo de plantillas de ejemplo, igual para todos",
"UmindOAuthCallbackHandler": "el alcance viene en el state firmado, no en la URL",
"CreateUmindTenantHandler": "crea el tenant: no hay uno previo contra el cual validar; solo staff",
"GetUmindPlanesHandler": "planes del sistema, solo staff",
"CreateUmindPlanHandler": "planes del sistema, solo staff",
"UpdateUmindPlanHandler": "planes del sistema, solo staff",
"DeleteUmindPlanHandler": "planes del sistema, solo staff",
}
func TestTodoHandlerUmindValidaAlcance(t *testing.T) {
archivos, err := filepath.Glob("umind_*.go")
if err != nil {
t.Fatal(err)
}
re := regexp.MustCompile(`func ([A-Z]\w*)\(c \*fiber\.Ctx\) error \{`)
revisados := 0
for _, f := range archivos {
if strings.HasSuffix(f, "_test.go") || strings.Contains(f, "scope_helpers") {
continue
}
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, m := range re.FindAllStringSubmatchIndex(s, -1) {
nombre := s[m[2]:m[3]]
ini := m[1]
fin := strings.Index(s[ini:], "\nfunc ")
cuerpo := s[ini:]
if fin > 0 {
cuerpo = s[ini : ini+fin]
}
revisados++
tiene := false
for _, h := range helpersDeAlcance {
if strings.Contains(cuerpo, h) {
tiene = true
break
}
}
if tiene {
if _, exento := sinAlcanceJustificado[nombre]; exento {
t.Errorf("%s (%s) ya valida alcance: sacalo de sinAlcanceJustificado", nombre, f)
}
continue
}
if _, exento := sinAlcanceJustificado[nombre]; exento {
continue
}
t.Errorf("%s (%s) no valida el alcance del tenant.\n"+
" Usá accesoTenant/accesoAgente al entrar, o agregalo a sinAlcanceJustificado con el motivo.\n"+
" Sin esto, un cliente puede leer los datos de otro pasando el id en la URL.", nombre, f)
}
}
if revisados < 30 {
t.Fatalf("solo se revisaron %d handlers: el detector dejó de encontrarlos y el test no está protegiendo nada", revisados)
}
t.Logf("%d handlers uMind revisados", revisados)
}
+6 -6
View File
@@ -65,21 +65,21 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
g.Get("/umind/conexiones", r(controllers.GetUmindConexionesHandler)...)
g.Get("/umind/conexiones/conectar", w(controllers.UmindConectarHandler)...)
g.Get("/umind/vigilancias", w(controllers.GetUmindVigilanciasHandler)...)
g.Get("/umind/vigilancias", r(controllers.GetUmindVigilanciasHandler)...)
g.Delete("/umind/vigilancias/:id", w(controllers.DeleteUmindVigilanciaHandler)...)
g.Get("/umind/plantillas", w(controllers.GetUmindPlantillasHandler)...)
g.Get("/umind/plantillas", r(controllers.GetUmindPlantillasHandler)...)
g.Post("/umind/plantillas", w(controllers.CreateUmindPlantillaHandler)...)
g.Post("/umind/plantillas/importar", w(controllers.ImportarUmindPlantillaHandler)...)
g.Delete("/umind/plantillas/:id", w(controllers.DeleteUmindPlantillaHandler)...)
g.Get("/umind/acciones", w(controllers.GetUmindAccionesHandler)...)
g.Get("/umind/acciones", r(controllers.GetUmindAccionesHandler)...)
g.Post("/umind/acciones/:id/aprobar", w(controllers.AprobarUmindAccionHandler)...)
g.Post("/umind/acciones/:id/rechazar", w(controllers.RechazarUmindAccionHandler)...)
g.Get("/umind/archivos", w(controllers.GetUmindArchivosHandler)...)
g.Get("/umind/archivos", r(controllers.GetUmindArchivosHandler)...)
g.Post("/umind/archivos", w(controllers.CreateUmindArchivoRepoHandler)...)
g.Get("/umind/archivos/:id/descargar", w(controllers.DownloadUmindArchivoHandler)...)
g.Get("/umind/archivos/:id/descargar", r(controllers.DownloadUmindArchivoHandler)...)
g.Post("/umind/archivos/:id/conocimiento", w(controllers.UsarArchivoComoConocimientoHandler)...)
g.Delete("/umind/archivos/:id", w(controllers.DeleteUmindArchivoHandler)...)
g.Get("/umind/avisos", w(controllers.GetUmindAvisosHandler)...)
g.Get("/umind/avisos", r(controllers.GetUmindAvisosHandler)...)
g.Delete("/umind/avisos/:id", w(controllers.DeleteUmindAvisoHandler)...)
g.Post("/umind/conexiones/imap", w(controllers.CrearConexionImapHandler)...)
g.Post("/umind/conexiones/:id/probar", w(controllers.ProbarConexionImapAgenteHandler)...)