Compare commits

...
2 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 5 6942c04d27 fix(usuarios): un usuario con tipo inesperado desaparecía de todas las listas
El administrador no aparecía en /app/users.

La lista de usuarios del sistema filtraba con una lista blanca
(tipo_usuario 'sistema', '' o NULL) y la pantalla de usuarios 'gas' es la
complementaria. Un usuario con cualquier otro valor —o con uno viejo de
antes de que existiera el campo— no salía en NINGUNA de las dos: quedaba
invisible en todo el panel aunque pudiera entrar y fuera administrador.

Ahora se excluye 'gas' en vez de exigir 'sistema', así todo usuario cae en
exactamente una de las dos listas.

Además la búsqueda usaba LIKE, que en Postgres distingue mayúsculas:
buscar "lizandro" no encontraba a "Lizandro", así que el atajo obvio para
comprobarlo tampoco funcionaba. Pasa a ILIKE y también busca por email,
que es lo que la mayoría escribe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:13:03 -05:00
Lizandro GuarnizoandClaude Sonnet 5 105ab44759 fix(permisos): compara la ruta completa y avisa de los menús que dan 404
Auditoría del sistema de permisos a partir del 404 en /app/doc/paginas.

1. La comparación de permisos usaba solo el último segmento de la URL
   (lo que va después del último '/'). Rompía en las dos direcciones: con
   permiso sobre /app/doc/categorias se entraba a cualquier otra ruta
   terminada en "categorias", y a la vez el permiso parecía no aplicarse
   donde sí correspondía porque el segmento coincidía por casualidad.
   Ahora se compara la ruta completa, cortando en el separador para que
   /app/doc/paginas no habilite /app/doc/paginas-privadas.

2. El submódulo de Documentación nunca se sembró, aunque las rutas y las
   vistas existen desde mayo. Había que crear el ítem del menú a mano, y
   una URL mal tipeada ahí se ve exactamente igual que un permiso mal
   asignado: un 404. Ahora se siembra con la URL correcta.

3. El submódulo "Statuspage" apuntaba a /app/statuspage, que no existe en
   ninguna parte — la página real es la pública /status. Se corrige el
   seed y también el registro ya creado en la base.

4. Chequeo en el arranque que recorre los submódulos de la base y avisa
   cuáles apuntan a una URL sin ruta. Cubre los creados a mano, que es
   justo donde el compilador y los tests no llegan.

5. Test que cruza las URLs sembradas contra las rutas registradas: fue el
   que encontró lo de statuspage.

Queda pendiente y es más grande: de 454 rutas protegidas solo 54 pasan por
MenuMiddleware. El permiso gatea la página HTML pero no los endpoints de
datos — cualquier usuario autenticado puede llamar a
GET /app/doc/loadpaginas o DELETE /app/doc/paginas/:id sin tener el
submódulo asignado. Se reporta antes de tocarlo porque cerrarlo de golpe
puede dejar gente afuera.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 09:21:44 -05:00
6 changed files with 298 additions and 26 deletions
+15
View File
@@ -198,6 +198,7 @@ func main() {
migrations.SeedPagosExternos()
migrations.SeedAutomatizacionIA()
migrations.SeedUmind()
migrations.SeedDocumentacion()
// Va antes de MigrarUmindAgentes: libera las columnas huérfanas que
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en
// uMind (ver el comentario de la función).
@@ -228,6 +229,20 @@ func main() {
defer services.DetenerCron()
// Cargar rutas
routes.LoadRoutes(app.Http.Server.App)
// Con las rutas ya montadas se puede avisar de los ítems del menú que
// apuntan a una URL inexistente — el síntoma es un 404 que parece un
// problema de permisos.
rutasGET := map[string]bool{}
for _, capa := range app.Http.Server.Stack() {
for _, r := range capa {
if r.Method == "GET" {
rutasGET[r.Path] = true
}
}
}
migrations.VerificarUrlsDeSubmodulos(rutasGET)
app.Http.Route404()
log.Fatal(app.Http.Server.ServeWithGraceFullShutdown())
}
+90 -1
View File
@@ -3,6 +3,7 @@ package migrations
import (
"fmt"
"log"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -239,9 +240,15 @@ func SeedStatuspage() {
}
entries := []struct{ title, desc, url string }{
{"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/app/statuspage"},
// La página real es la pública /status (rest/routes/publicas.go):
// /app/statuspage nunca existió y el ítem daba 404 al entrar.
{"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/status"},
}
// La URL vieja apuntaba a /app/statuspage, que no existe: se corrige el
// registro ya creado en vez de dejar un ítem de menú que da 404.
db.Model(&models.Submodules{}).Where("url = ?", "/app/statuspage").Update("url", "/status")
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
@@ -1533,3 +1540,85 @@ func LiberarColumnasHuerfanasUmind() {
log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna)
}
}
// VerificarUrlsDeSubmodulos avisa en el arranque de los ítems del menú cuya
// URL no corresponde a ninguna ruta registrada.
//
// Un submódulo es una URL escrita a mano (en un seed o desde /app/submodules).
// Si no coincide con una ruta real, el ítem igual aparece en el menú y se le
// puede asignar permiso a un rol — y recién al hacer clic aparece un 404, sin
// nada que explique por qué. Esto lo convierte en una línea de log al
// arrancar, en vez de un misterio en producción.
//
// Solo informa: no borra ni modifica nada, porque una URL "rota" puede ser una
// ruta servida por otro lado.
func VerificarUrlsDeSubmodulos(rutasGET map[string]bool) {
var subs []models.Submodules
if err := app.Http.Database.DB.Find(&subs).Error; err != nil {
log.Printf("[MENU] no se pudieron revisar las URLs de los submódulos: %v", err)
return
}
rotos := 0
for _, s := range subs {
url := strings.TrimSpace(s.Url)
if url == "" {
log.Printf("[MENU] el submódulo %q (ID %d) no tiene URL", s.Title, s.ID)
rotos++
continue
}
if !rutasGET[url] {
log.Printf("[MENU] el submódulo %q (ID %d) apunta a %q, que no es ninguna ruta: va a dar 404", s.Title, s.ID, url)
rotos++
}
}
if rotos == 0 {
log.Printf("[MENU] %d submódulos revisados, todas las URLs resuelven", len(subs))
} else {
log.Printf("[MENU] %d de %d submódulos apuntan a una URL inexistente (ver líneas anteriores)", rotos, len(subs))
}
}
// SeedDocumentacion registra los submódulos de Documentación.
//
// Existían las rutas y las vistas pero nunca el seed, así que había que crear
// el ítem del menú a mano desde /app/submodules — y una URL mal tipeada ahí es
// indistinguible de un permiso mal asignado: en los dos casos se ve un 404.
func SeedDocumentacion() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Documentación").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Documentación",
Description: "Páginas y categorías de documentación interna",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Documentación: %v", err)
return
}
}
entries := []struct{ title, desc, url string }{
{"Doc: Páginas", "Páginas de documentación", "/app/doc/paginas"},
{"Doc: Categorías", "Categorías de documentación", "/app/doc/categorias"},
}
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
}
}
}
+20 -6
View File
@@ -45,9 +45,12 @@ func AllUsersGas(limit, offset int, search string) ([]Users, int64, error) {
var total int64
db := app.Http.Database.DB.Model(&Users{})
// Filtrar por término de búsqueda si se proporciona
// ILIKE y no LIKE: en Postgres LIKE distingue mayúsculas, así que buscar
// "lizandro" no encontraba a "Lizandro". Se agrega el email porque es lo
// que la mayoría escribe cuando busca a una persona.
if search != "" {
db = db.Where("name LIKE ? OR nombre_usuario LIKE ?", "%"+search+"%", "%"+search+"%")
patron := "%" + search + "%"
db = db.Where("name ILIKE ? OR nombre_usuario ILIKE ? OR email ILIKE ?", patron, patron, patron)
}
// Filtrar por tipo_usuario = 'gas'
db = db.Where("tipo_usuario = ?", "gas")
@@ -72,13 +75,24 @@ func AllUsersSistema(limit, offset int, search string) ([]Users, int64, error) {
var total int64
db := app.Http.Database.DB.Model(&Users{})
// Filtrar por término de búsqueda si se proporciona
// ILIKE y no LIKE: en Postgres LIKE distingue mayúsculas, así que buscar
// "lizandro" no encontraba a "Lizandro". Se agrega el email porque es lo
// que la mayoría escribe cuando busca a una persona.
if search != "" {
db = db.Where("name LIKE ? OR nombre_usuario LIKE ?", "%"+search+"%", "%"+search+"%")
patron := "%" + search + "%"
db = db.Where("name ILIKE ? OR nombre_usuario ILIKE ? OR email ILIKE ?", patron, patron, patron)
}
// Filtrar por tipo_usuario = 'sistema' o vacío (excluye usuarios 'gas' y otros tipos)
db = db.Where("tipo_usuario = ? OR tipo_usuario = '' OR tipo_usuario IS NULL", "sistema")
// Se excluye 'gas' en vez de exigir 'sistema'.
//
// Antes era una lista blanca ('sistema', '' o NULL) y la pantalla de 'gas'
// es la complementaria, así que un usuario con cualquier otro valor —o con
// uno viejo de antes de que existiera el campo— no aparecía en NINGUNA de
// las dos listas: quedaba invisible en todo el panel aunque pudiera entrar
// y fuera administrador.
//
// Invertirlo garantiza que todo usuario esté en exactamente una de las dos.
db = db.Where("COALESCE(tipo_usuario, '') <> ?", "gas")
// Obtener el total de usuarios
if err := db.Count(&total).Error; err != nil {
+34 -19
View File
@@ -87,25 +87,15 @@ func MenuMiddleware(c *fiber.Ctx) error {
return c.Next()
}
// Verificar si la URL de la solicitud está en la lista de URLs permitidas
requestURL := c.Path()
// Obtener el índice del último '/' en la URL de la solicitud
lastSlashIndex := strings.LastIndex(requestURL, "/")
if lastSlashIndex != -1 {
// Obtener solo la parte de la URL después del último '/'
requestURL = requestURL[lastSlashIndex+1:]
}
// Verificar si la URL modificada está en la lista de URLs permitidas
urlAllowed := false
for _, url := range urls {
// Comparar solo la parte de la URL después del último '/'
if requestURL == url[strings.LastIndex(url, "/")+1:] {
urlAllowed = true
break
}
}
// Se compara la ruta COMPLETA contra las URLs permitidas.
//
// Antes se comparaba solo el último segmento (lo que va después del último
// '/'), y eso rompía en las dos direcciones: dos rutas distintas que
// terminan igual quedaban indistinguibles — con permiso sobre
// /app/doc/categorias se entraba a cualquier otra ruta terminada en
// "categorias" — y a la vez el permiso parecía no aplicarse donde sí
// debía, porque el segmento coincidía por casualidad.
urlAllowed := PuedeVerRuta(c.Path(), urls)
// Si la URL no está permitida, devolver un error
if !urlAllowed {
@@ -118,3 +108,28 @@ func MenuMiddleware(c *fiber.Ctx) error {
// Continuar con la siguiente función de middleware o manejador
return c.Next()
}
// PuedeVerRuta decide si una ruta está cubierta por alguna de las URLs que el
// rol tiene asignadas.
//
// Coincide la ruta exacta o cualquier ruta por debajo de ella: quien tiene
// /app/doc/paginas también puede entrar a /app/doc/paginas/7, que es la misma
// pantalla con un detalle. No coincide /app/doc/paginas-privadas, porque el
// corte se hace en el separador y no en el prefijo de texto — si no, un
// permiso abriría rutas vecinas que solo comparten el comienzo del nombre.
func PuedeVerRuta(ruta string, permitidas []string) bool {
ruta = strings.TrimRight(ruta, "/")
if ruta == "" {
ruta = "/"
}
for _, u := range permitidas {
u = strings.TrimRight(strings.TrimSpace(u), "/")
if u == "" {
continue
}
if ruta == u || strings.HasPrefix(ruta, u+"/") {
return true
}
}
return false
}
+49
View File
@@ -0,0 +1,49 @@
package middlewares
import "testing"
// El permiso comparaba solo el último segmento de la URL, así que dos rutas
// distintas terminadas igual eran indistinguibles. Estos casos fijan que la
// comparación es sobre la ruta completa y corta en el separador.
func TestPuedeVerRuta(t *testing.T) {
permisos := []string{"/app/doc/paginas", "/app/clientes", "/status"}
casos := []struct {
ruta string
esperado bool
porque string
}{
{"/app/doc/paginas", true, "coincidencia exacta"},
{"/app/doc/paginas/7", true, "el detalle cuelga de la pantalla permitida"},
{"/app/doc/paginas/", true, "la barra final no cambia nada"},
{"/app/clientes", true, "otra pantalla permitida"},
{"/status", true, "una ruta pública también puede ser un ítem del menú"},
{"/app/doc/categorias", false, "otra pantalla del mismo módulo"},
{"/app/umind", false, "sin permiso"},
{"/app/doc", false, "el padre no se hereda del hijo"},
// El bug original: comparaba solo lo que va después del último '/'.
{"/app/otro/paginas", false, "termina igual pero es otra ruta"},
// El corte va en el separador, no en el prefijo de texto.
{"/app/doc/paginas-privadas", false, "solo comparte el comienzo del nombre"},
{"/app/clientes-vip", false, "prefijo parecido, ruta distinta"},
}
for _, cas := range casos {
if got := PuedeVerRuta(cas.ruta, permisos); got != cas.esperado {
t.Errorf("PuedeVerRuta(%q) = %v, esperaba %v — %s", cas.ruta, got, cas.esperado, cas.porque)
}
}
}
// Sin permisos no se entra a ningún lado (los administradores se resuelven
// antes, con user.IsAdmin).
func TestPuedeVerRutaSinPermisos(t *testing.T) {
if PuedeVerRuta("/app/clientes", nil) {
t.Error("un rol sin submódulos no debería acceder a nada")
}
if PuedeVerRuta("/app/clientes", []string{"", " "}) {
t.Error("una URL vacía no debería habilitar nada")
}
}
+90
View File
@@ -0,0 +1,90 @@
package routes
import (
"os"
"regexp"
"sort"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
// Un submódulo del menú es una URL escrita a mano en el seed. Si no coincide
// con ninguna ruta registrada, el ítem aparece en el menú, se le puede asignar
// permiso a un rol, y al hacer clic da 404 — que es exactamente el síntoma
// reportado en /app/doc/paginas. El compilador no ve nada de esto porque son
// cadenas sueltas en dos archivos distintos.
func TestUrlsDeSubmodulosTienenRuta(t *testing.T) {
rutas := rutasGETRegistradas(t)
urls, err := urlsSembradas()
if err != nil {
t.Skipf("no se pudo leer el seed: %v", err)
}
if len(urls) == 0 {
t.Fatal("no se encontró ninguna URL de submódulo en el seed")
}
var rotas []string
for _, u := range urls {
if !rutas[u] {
rotas = append(rotas, u)
}
}
sort.Strings(rotas)
for _, u := range rotas {
t.Errorf("el submódulo %q no tiene ruta registrada: el menú lo muestra y da 404 al entrar", u)
}
}
// rutasGETRegistradas devuelve el conjunto de rutas GET que sirven una página.
func rutasGETRegistradas(t *testing.T) map[string]bool {
t.Helper()
// LoadRoutes monta todos los grupos: un submódulo puede apuntar a una ruta
// pública (ej. /status) y no solo a /app/*.
app := fiber.New()
LoadRoutes(app)
out := map[string]bool{}
for _, capa := range app.Stack() {
for _, r := range capa {
if r.Method == "GET" {
out[r.Path] = true
}
}
}
return out
}
// Solo las tuplas {título, descripción, url} de los slices de entries y las
// asignaciones Url:. Buscar cualquier "/app/..." suelto daba falsos positivos
// con las líneas que corrigen una URL vieja en la base.
var reURLSubmodulo = regexp.MustCompile(`\{"[^"]*",\s*"[^"]*",\s*"(/[a-z0-9/_-]+)"\}|Url:\s*"(/[a-z0-9/_-]+)"`)
// urlsSembradas saca las URLs de submódulo del seed. Se lee el fuente porque
// los seeds son literales en el código, no datos que se puedan consultar.
func urlsSembradas() ([]string, error) {
b, err := os.ReadFile("../../migrations/migrate.go")
if err != nil {
return nil, err
}
texto := string(b)
vistas := map[string]bool{}
var out []string
for _, m := range reURLSubmodulo.FindAllStringSubmatch(texto, -1) {
u := m[1]
if u == "" {
u = m[2]
}
// Solo interesan las que se usan como Url de un submódulo; las de
// redirección o comparación quedan cubiertas igual y no molestan.
if vistas[u] || strings.Contains(u, "//") {
continue
}
vistas[u] = true
out = append(out, u)
}
return out, nil
}