Files
soft_usite/pkg/services/umind_aprobacion_service.go
T
Lizandro GuarnizoandClaude Opus 5 b7c52f174a 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>
2026-08-24 22:04:03 -05:00

192 lines
7.5 KiB
Go

package services
import (
"encoding/json"
"fmt"
"log"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// La regla que separa "asistente" de "incidente": preparar es libre, entregar
// necesita una persona — cuando del otro lado no está el dueño.
// RequiereAprobacion decide si esta llamada se ejecuta o se encola.
//
// El eje es quién está del otro lado, no qué tan peligrosa suena la acción.
// En un canal interno el dueño ya autorizó al escribirlo, y mandarlo a
// aprobar su propio pedido es fricción sin seguridad. En el widget público
// escribe cualquiera: ahí toda acción hacia afuera espera.
func RequiereAprobacion(sesionInterna bool, name string) bool {
if sesionInterna {
return false
}
switch name {
case "buscar_conocimiento", "leer_bandeja":
return false // lectura: no sale nada del negocio
}
return true
}
func tipoDeAccion(name string) string {
switch {
case name == "enviar_correo":
return "correo"
case strings.HasPrefix(name, "generar_documento"):
return "documento"
}
return "herramienta"
}
// EncolarAccion deja la acción esperando y avisa al dueño. Devuelve lo que el
// modelo le va a contar al cliente: nunca "fue rechazado" ni "está pendiente
// de aprobación interna" — el cliente no tiene por qué conocer la cocina.
func EncolarAccion(agenteID uint, sessionID, name string, args map[string]interface{}) string {
agente, err := models.GetUmindAgenteByID(agenteID)
if err != nil {
return `{"error": "no se pudo registrar la solicitud"}`
}
argsJSON, _ := json.Marshal(args)
accion := &models.UmindAccionPendiente{
AgenteID: agenteID, TenantID: agente.TenantID,
Tipo: tipoDeAccion(name), Herramienta: name,
ArgumentosJSON: string(argsJSON), Resumen: resumirAccion(name, args),
SessionID: sessionID, Estado: "pendiente",
}
if err := models.CreateUmindAccion(accion); err != nil {
log.Printf("[UMIND_APROB] no se pudo encolar la acción %q del agente %d: %v", name, agenteID, err)
return `{"error": "no se pudo registrar la solicitud, intentá más tarde"}`
}
go avisarAccionPendiente(agente, accion)
b, _ := json.Marshal(map[string]interface{}{
"ok": true,
"nota": "La solicitud quedó registrada y una persona del equipo la va a revisar. Decíselo al cliente de forma natural, sin mencionar aprobaciones ni procesos internos.",
})
return string(b)
}
// resumirAccion arma la línea que va a leer quien aprueba.
func resumirAccion(name string, args map[string]interface{}) string {
switch name {
case "enviar_correo":
return fmt.Sprintf("Enviar un correo a %v con asunto %q", args["destinatario"], args["asunto"])
case "generar_documento":
return fmt.Sprintf("Generar y entregar un documento (%v) para %v", args["tipo"], args["cliente"])
}
partes := make([]string, 0, len(args))
for k, v := range args {
partes = append(partes, fmt.Sprintf("%s: %v", k, v))
}
return fmt.Sprintf("Ejecutar %q con %s", name, strings.Join(partes, ", "))
}
// EjecutarAccionAprobada corre el payload guardado, tal cual se aprobó.
func EjecutarAccionAprobada(accion *models.UmindAccionPendiente, quien string) error {
if !models.ReclamarAccion(accion.ID) {
return fmt.Errorf("esa solicitud ya fue resuelta")
}
var args map[string]interface{}
if err := json.Unmarshal([]byte(accion.ArgumentosJSON), &args); err != nil {
_ = models.CerrarAccion(accion.ID, "fallida", quien, "los datos guardados no se pudieron leer", "")
return err
}
// Se ejecuta como sesión interna porque ya pasó por una persona; ese es
// exactamente el permiso que la aprobación otorga.
resultado := ejecutarHerramienta(accion.AgenteID, accion.SessionID, true, accion.Herramienta, args)
estado := "ejecutada"
if strings.Contains(resultado, `"error"`) {
estado = "fallida"
}
if err := models.CerrarAccion(accion.ID, estado, quien, "", resultado); err != nil {
return err
}
// Si la acción produjo un documento, se engancha a la acción para que el
// que aprobó pueda abrirlo desde la misma fila donde apretó el botón.
if id := archivoDelResultado(resultado); id != nil {
models.VincularArchivoAAccion(accion.ID, *id)
}
if estado == "fallida" {
models.RegistrarEventoUmind(accion.AgenteID, "error", "aprobacion",
"Una acción aprobada falló al ejecutarse: "+accion.Resumen, resultado)
return fmt.Errorf("la acción se aprobó pero falló al ejecutarse")
}
return nil
}
// avisarAccionPendiente le dice al dueño que tiene algo para revisar. El
// correo lleva un enlace al panel, con login: un enlace que ejecuta algo
// irreversible sin autenticar es un enlace que reenviado por error firma.
func avisarAccionPendiente(agente *models.UmindAgente, accion *models.UmindAccionPendiente) {
tenant, err := models.GetUmindTenantByID(agente.TenantID)
if err != nil || tenant.ClienteID == nil {
return
}
usuarios, err := models.GetPortalUsersByClienteID(*tenant.ClienteID)
if err != nil {
return
}
url := GetPublicURL() + "/portal/studio"
for _, u := range usuarios {
models.CreateSistemaNotif(&models.SistemaNotificacion{
TipoUsuario: "portal_user", UsuarioID: u.ID,
Titulo: "Tu asistente necesita tu visto bueno",
Cuerpo: accion.Resumen, Icono: "✋", Url: url,
})
if u.Email == "" {
continue
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html><body style="font-family:Inter,sans-serif;background:#f1f5f9;padding:32px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;padding:32px;border:1px solid #e2e8f0">
<p style="color:#64748b;font-size:13px;margin:0 0 8px">uMind · %s</p>
<h2 style="margin:0 0 12px;color:#1e293b;font-size:20px">Tu asistente preparó algo y espera tu visto bueno</h2>
<p style="color:#334155;font-size:15px;background:#f8fafc;border-left:3px solid #8eb02f;padding:12px 16px;border-radius:6px">%s</p>
<a href="%s" style="display:block;text-align:center;background:#8eb02f;color:#fff;padding:12px 24px;border-radius:10px;font-weight:600;text-decoration:none;font-size:15px;margin:24px 0">Revisarlo</a>
<p style="color:#94a3b8;font-size:12px">Si no lo revisás, vence en 7 días y no se hace nada.</p>
</div></body></html>`, agente.Nombre, accion.Resumen, url)
if err := app.Http.Mail.Send(u.Email, "Tu asistente necesita tu visto bueno", html); err != nil {
log.Printf("[UMIND_APROB] no se pudo avisar a %s: %v", u.Email, err)
}
}
}
// CategoriaTool clasifica la llamada ANTES de decidir qué hacer con ella:
//
// lectura — no sale nada del negocio, se ejecuta siempre
// interna — gasta el plan del dueño, solo desde su canal privado
// accion — sale hacia afuera, espera aprobación si el pedido es público
// desconocida — el nombre no existe: se rechaza, nunca se encola
//
// La clasificación existe porque sin ella todo lo que no fuera lectura se
// encolaba, incluidos los nombres que el modelo inventa.
func CategoriaTool(agenteID uint, name string) string {
switch name {
case "buscar_conocimiento", "leer_bandeja":
return "lectura"
case "programar_aviso", "listar_avisos", "cancelar_aviso",
"crear_vigilancia", "listar_vigilancias", "cancelar_vigilancia":
return "interna"
case "enviar_correo", "generar_documento":
return "accion"
}
if _, err := models.GetUmindHerramientaByNombre(agenteID, name); err == nil {
return "accion"
}
return "desconocida"
}
// archivoDelResultado saca el archivo_id que devuelve generar_documento.
func archivoDelResultado(resultado string) *uint {
var r struct {
ArchivoID uint `json:"archivo_id"`
}
if err := json.Unmarshal([]byte(resultado), &r); err != nil || r.ArchivoID == 0 {
return nil
}
return &r.ArchivoID
}