feat(umind): el cliente administra sus agentes desde el portal
Acá es donde uMind deja de ser una herramienta interna: el cliente entra a /portal/studio con su sesión de portal y gestiona lo suyo. - UmindScopePortal/UmindScopeStaff es el ÚNICO punto donde se decide el alcance. El del cliente sale de GetClienteIDsForPortalUser, el mismo que ya autoriza el resto del portal. nil = staff sin restricción, slice vacío = no ve nada; una ruta sin scope también cae en "no ve nada" para que olvidarse el middleware falle visible y no abra todo. - Un solo set de handlers montado bajo /app/umind y /portal/umind (RegistrarRutasUmind). Duplicarlos sería duplicar las chances de olvidar un chequeo. - Guarda de acceso en TODOS los handlers, incluidos los sub-recursos que llegan por :id (documento, tool, canal, conexión): hay que cargarlos para saber de quién son, si no un cliente podría borrar el canal de otro adivinando el id. Responden 404, no 403: un 403 confirmaría que el recurso existe. - Cierra un bug preexistente: las lecturas GET /app/umind/* no tenían SoloAdmin ni pasaban por MenuMiddleware, así que cualquier usuario de staff podía leer los tenants de todos los clientes. - Límite de agentes por plan (409 con mensaje claro). Un tenant sin plan no tiene límite: cortarles de golpe sería peor que dejarlos como estaban. - /umind/ai-configs reemplaza con alcance a /app/api/ai-config/select, que devolvía TODAS las configs del sistema. - El SPA deduce por la URL si es staff o cliente (base del router, prefijo de API y URL de login) y oculta lo que es solo de staff. - Test de aislamiento entre clientes: 7 casos, incluido que un scope vacío no se confunda con staff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
08265510ea
commit
5b78f6677c
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
||||
)
|
||||
|
||||
// UmindIndex renderiza el panel de administración de uMind.
|
||||
@@ -50,6 +51,12 @@ func GetUmindTenants(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
items = tenantsVisibles(c, items)
|
||||
if _, esStaff := middlewares.UmindScopeDe(c); !esStaff {
|
||||
// Para el cliente la paginación global no tiene sentido (ve pocos
|
||||
// tenants); el total pasa a ser el de lo que realmente puede ver.
|
||||
total = int64(len(items))
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": total,
|
||||
@@ -109,6 +116,9 @@ func UpdateUmindTenantHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoTenant(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
var req umindTenantReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
@@ -132,6 +142,9 @@ func DeleteUmindTenantHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoTenant(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := models.DeleteUmindTenant(uint(id)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
@@ -148,6 +161,12 @@ func GetUmindAgentesHandler(c *fiber.Ctx) error {
|
||||
if err != nil || tenantID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
||||
}
|
||||
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := models.GetUmindAgentesByTenant(uint(tenantID))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -176,9 +195,15 @@ func CreateUmindAgenteHandler(c *fiber.Ctx) error {
|
||||
if strings.TrimSpace(req.Nombre) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
|
||||
}
|
||||
if err := accesoTenant(c, req.TenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := models.GetUmindTenantByID(req.TenantID); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"})
|
||||
}
|
||||
if err := verificarCupoAgentes(c, req.TenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agente := &models.UmindAgente{
|
||||
TenantID: req.TenantID,
|
||||
@@ -200,6 +225,9 @@ func UpdateUmindAgenteHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
var req umindAgenteReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
@@ -223,6 +251,9 @@ func DeleteUmindAgenteHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := models.DeleteUmindAgente(uint(id)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
@@ -236,6 +267,9 @@ func GetUmindDocumentosHandler(c *fiber.Ctx) error {
|
||||
if err != nil || agenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := models.GetUmindDocumentosByAgente(uint(agenteID))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -258,6 +292,9 @@ func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||
if req.AgenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(req.URL) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url requerida"})
|
||||
}
|
||||
@@ -285,6 +322,9 @@ func DeleteUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoDocumento(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := models.DeleteUmindDocumento(uint(id)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
@@ -298,6 +338,9 @@ func GetUmindSesionesHandler(c *fiber.Ctx) error {
|
||||
if err != nil || agenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := models.GetUmindSesiones(uint(agenteID), 50)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -310,6 +353,9 @@ func GetUmindHistorialHandler(c *fiber.Ctx) error {
|
||||
if err != nil || agenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||
return err
|
||||
}
|
||||
sessionID := c.Query("session_id")
|
||||
if sessionID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "session_id requerido"})
|
||||
@@ -330,6 +376,9 @@ func GetUmindHerramientasHandler(c *fiber.Ctx) error {
|
||||
if err != nil || agenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := models.GetUmindHerramientasByAgente(uint(agenteID))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -365,6 +414,9 @@ func CreateUmindHerramientaHandler(c *fiber.Ctx) error {
|
||||
if req.AgenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !umindNombreToolRegex.MatchString(req.Nombre) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
|
||||
}
|
||||
@@ -400,6 +452,9 @@ func UpdateUmindHerramientaHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoHerramienta(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
var req umindHerramientaReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
@@ -442,6 +497,9 @@ func DeleteUmindHerramientaHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoHerramienta(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := models.DeleteUmindHerramienta(uint(id)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
@@ -455,6 +513,9 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error {
|
||||
if err != nil || agenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := models.GetUmindCanalesByAgente(uint(agenteID))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -493,6 +554,9 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
||||
if req.AgenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
||||
return err
|
||||
}
|
||||
switch req.Tipo {
|
||||
case "telegram":
|
||||
if strings.TrimSpace(req.Credenciales["bot_token"]) == "" {
|
||||
@@ -534,6 +598,9 @@ func UpdateUmindCanalHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoCanal(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
var req umindCanalReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
@@ -558,6 +625,9 @@ func DeleteUmindCanalHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoCanal(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := models.DeleteUmindCanal(uint(id)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
@@ -581,9 +651,9 @@ func UmindChatPruebaHandler(c *fiber.Ctx) error {
|
||||
if strings.TrimSpace(req.Mensaje) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "mensaje requerido"})
|
||||
}
|
||||
agente, err := models.GetUmindAgenteByID(req.AgenteID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
|
||||
agente, errAcceso := accesoAgente(c, req.AgenteID)
|
||||
if errAcceso != nil {
|
||||
return errAcceso
|
||||
}
|
||||
sessionID := strings.TrimSpace(req.SessionID)
|
||||
if sessionID == "" {
|
||||
@@ -606,6 +676,9 @@ func GetUmindEventosHandler(c *fiber.Ctx) error {
|
||||
if err != nil || agenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := models.GetUmindEventosByAgente(uint(agenteID), 100)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -623,6 +696,9 @@ func GetUmindUsoHandler(c *fiber.Ctx) error {
|
||||
if err != nil || tenantID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
||||
}
|
||||
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
||||
return err
|
||||
}
|
||||
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
|
||||
|
||||
resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta)
|
||||
|
||||
@@ -17,6 +17,9 @@ func GetUmindConexionesHandler(c *fiber.Ctx) error {
|
||||
if err != nil || agenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := models.GetUmindConexionesByAgente(uint(agenteID))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -39,8 +42,8 @@ func UmindConectarHandler(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
proveedor := c.Query("proveedor")
|
||||
if _, err := models.GetUmindAgenteByID(uint(agenteID)); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
|
||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url, err := services.IniciarConexionOAuth(proveedor, uint(agenteID))
|
||||
@@ -81,6 +84,9 @@ func DeleteUmindConexionHandler(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
|
||||
}
|
||||
if err := accesoConexion(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := models.DeleteUmindConexion(uint(id)); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
||||
)
|
||||
|
||||
// errSinAcceso es la respuesta uniforme cuando el que llama no tiene alcance
|
||||
// sobre el recurso. Se usa 404 y no 403 a propósito: un 403 confirmaría que
|
||||
// el tenant/agente existe, y eso ya es información de otro cliente.
|
||||
func errSinAcceso(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no encontrado"})
|
||||
}
|
||||
|
||||
// accesoTenant valida que el que llama pueda operar sobre ese tenant.
|
||||
func accesoTenant(c *fiber.Ctx, tenantID uint) error {
|
||||
if tenantID == 0 || !middlewares.UmindPuedeVerTenant(c, tenantID) {
|
||||
return errSinAcceso(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// accesoAgente resuelve el agente a su tenant y valida el alcance. Devuelve el
|
||||
// agente ya cargado para no volver a buscarlo en el handler.
|
||||
func accesoAgente(c *fiber.Ctx, agenteID uint) (*models.UmindAgente, error) {
|
||||
if agenteID == 0 {
|
||||
return nil, errSinAcceso(c)
|
||||
}
|
||||
agente, err := models.GetUmindAgenteByID(agenteID)
|
||||
if err != nil {
|
||||
return nil, errSinAcceso(c)
|
||||
}
|
||||
if !middlewares.UmindPuedeVerTenant(c, agente.TenantID) {
|
||||
return nil, errSinAcceso(c)
|
||||
}
|
||||
return agente, nil
|
||||
}
|
||||
|
||||
// Los sub-recursos (documento, tool, canal, conexión) llegan por :id y no
|
||||
// dicen a qué agente pertenecen: hay que cargarlos para resolver el dueño
|
||||
// antes de dejar operar sobre ellos. Sin esto, un cliente podría borrar el
|
||||
// canal de otro adivinando el id.
|
||||
|
||||
func accesoDocumento(c *fiber.Ctx, id uint) error {
|
||||
doc, err := models.GetUmindDocumentoByID(id)
|
||||
if err != nil {
|
||||
return errSinAcceso(c)
|
||||
}
|
||||
_, e := accesoAgente(c, doc.AgenteID)
|
||||
return e
|
||||
}
|
||||
|
||||
func accesoHerramienta(c *fiber.Ctx, id uint) error {
|
||||
h, err := models.GetUmindHerramientaByID(id)
|
||||
if err != nil {
|
||||
return errSinAcceso(c)
|
||||
}
|
||||
_, e := accesoAgente(c, h.AgenteID)
|
||||
return e
|
||||
}
|
||||
|
||||
func accesoCanal(c *fiber.Ctx, id uint) error {
|
||||
canal, err := models.GetUmindCanalByID(id)
|
||||
if err != nil {
|
||||
return errSinAcceso(c)
|
||||
}
|
||||
_, e := accesoAgente(c, canal.AgenteID)
|
||||
return e
|
||||
}
|
||||
|
||||
// verificarCupoAgentes aplica el límite de agentes del plan. Un tenant sin
|
||||
// plan asignado (los creados antes de que existieran los planes) no tiene
|
||||
// límite: cortarles el paso de golpe sería peor que dejarlos como estaban.
|
||||
func verificarCupoAgentes(c *fiber.Ctx, tenantID uint) error {
|
||||
plan := models.GetPlanDeTenant(tenantID)
|
||||
if plan == nil || plan.MaxAgentes <= 0 {
|
||||
return nil
|
||||
}
|
||||
agentes, err := models.GetUmindAgentesByTenant(tenantID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if len(agentes) >= plan.MaxAgentes {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||
"error": fmt.Sprintf("Tu plan %s permite %d agente(s) y ya los estás usando. Escribinos para ampliarlo.",
|
||||
plan.Nombre, plan.MaxAgentes),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tenantsVisibles filtra una lista de tenants al alcance del que llama.
|
||||
func tenantsVisibles(c *fiber.Ctx, items []models.UmindTenant) []models.UmindTenant {
|
||||
if _, esStaff := middlewares.UmindScopeDe(c); esStaff {
|
||||
return items
|
||||
}
|
||||
out := make([]models.UmindTenant, 0, len(items))
|
||||
for _, t := range items {
|
||||
if middlewares.UmindPuedeVerTenant(c, t.ID) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func accesoConexion(c *fiber.Ctx, id uint) error {
|
||||
cx, err := models.GetUmindConexionByID(id)
|
||||
if err != nil {
|
||||
return errSinAcceso(c)
|
||||
}
|
||||
_, e := accesoAgente(c, cx.AgenteID)
|
||||
return e
|
||||
}
|
||||
|
||||
// GetUmindAiConfigsHandler devuelve las configs de IA que el que llama puede
|
||||
// usar: las globales del staff más las propias de sus tenants. Es el
|
||||
// reemplazo con alcance de /app/api/ai-config/select, que devolvía TODAS las
|
||||
// configs del sistema — servírselo a un cliente le mostraría las de los demás.
|
||||
func GetUmindAiConfigsHandler(c *fiber.Ctx) error {
|
||||
tenantIDs, esStaff := middlewares.UmindScopeDe(c)
|
||||
|
||||
var items []models.AiConfig
|
||||
var err error
|
||||
if esStaff {
|
||||
items, err = models.GetAiConfigSelect()
|
||||
} else {
|
||||
items, err = models.GetAiConfigSelectPorTenants(tenantIDs)
|
||||
}
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
out := make([]fiber.Map, len(items))
|
||||
for i, it := range items {
|
||||
out[i] = fiber.Map{"ID": it.ID, "nombre": it.Nombre, "provider": it.Provider}
|
||||
}
|
||||
return c.JSON(fiber.Map{"items": out})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// umindScopeKey guarda en el contexto los tenants que el que llama puede ver.
|
||||
//
|
||||
// nil → staff, sin restricción
|
||||
// []uint{} → no ve nada (fail-closed)
|
||||
// []uint{1, 7} → solo esos tenants
|
||||
//
|
||||
// Es el ÚNICO lugar donde se decide el alcance: los handlers de uMind se
|
||||
// montan bajo /app/umind (staff) y /portal/umind (cliente) con distinto
|
||||
// middleware de scope, pero comparten implementación. Un bug acá es una fuga
|
||||
// de datos entre clientes, así que no se replica esta lógica en ningún lado.
|
||||
const umindScopeKey = "umind_tenant_ids"
|
||||
|
||||
// UmindScopeStaff deja el scope sin restricción. Existe como middleware
|
||||
// explícito (en vez de "no poner nada") para que una ruta sin scope sea un
|
||||
// error visible y no un permiso implícito.
|
||||
func UmindScopeStaff(c *fiber.Ctx) error {
|
||||
c.Locals(umindScopeKey, []uint(nil))
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// UmindScopePortal acota al cliente autenticado en el portal: sus tenants son
|
||||
// los de los Cliente a los que tiene acceso (uno si es cliente, varios si es
|
||||
// partner), resueltos por el mismo GetClienteIDsForPortalUser que ya autoriza
|
||||
// el resto del portal.
|
||||
func UmindScopePortal(c *fiber.Ctx) error {
|
||||
u := PortalUserFromLocals(c)
|
||||
if u == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autenticado"})
|
||||
}
|
||||
clienteIDs := models.GetClienteIDsForPortalUser(u)
|
||||
|
||||
tenants, err := models.GetUmindTenantsByClientes(clienteIDs)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo resolver el alcance"})
|
||||
}
|
||||
// Siempre un slice no-nil, incluso vacío: nil significa "staff, sin
|
||||
// restricción", y confundir los dos le daría acceso total a un cliente.
|
||||
ids := make([]uint, 0, len(tenants))
|
||||
for _, t := range tenants {
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
c.Locals(umindScopeKey, ids)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// UmindScopeDe devuelve (tenants permitidos, esStaff).
|
||||
func UmindScopeDe(c *fiber.Ctx) ([]uint, bool) {
|
||||
v := c.Locals(umindScopeKey)
|
||||
if v == nil {
|
||||
// Ruta sin middleware de scope: fail-closed. Mejor romper visiblemente
|
||||
// que servir datos de todos los clientes por omisión.
|
||||
return []uint{}, false
|
||||
}
|
||||
ids, ok := v.([]uint)
|
||||
if !ok {
|
||||
return []uint{}, false
|
||||
}
|
||||
return ids, ids == nil
|
||||
}
|
||||
|
||||
// UmindPuedeVerTenant es el chequeo que hacen los handlers.
|
||||
func UmindPuedeVerTenant(c *fiber.Ctx, tenantID uint) bool {
|
||||
ids, esStaff := UmindScopeDe(c)
|
||||
if esStaff {
|
||||
return true
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == tenantID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// El aislamiento entre clientes es la única lógica nueva donde un bug
|
||||
// significa que un cliente vea los datos de otro. Estos casos cubren el
|
||||
// contrato de UmindScopeDe/UmindPuedeVerTenant sin necesidad de base de datos.
|
||||
func TestUmindPuedeVerTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
|
||||
casos := []struct {
|
||||
nombre string
|
||||
scope interface{} // lo que quedó en Locals
|
||||
tenantID uint
|
||||
esperado bool
|
||||
}{
|
||||
{"staff ve cualquier tenant", []uint(nil), 42, true},
|
||||
{"cliente ve el suyo", []uint{7, 9}, 7, true},
|
||||
{"cliente ve el otro suyo", []uint{7, 9}, 9, true},
|
||||
{"cliente NO ve el de otro", []uint{7, 9}, 8, false},
|
||||
{"cliente sin tenants no ve nada", []uint{}, 1, false},
|
||||
{"ruta sin middleware de scope no ve nada", nil, 1, false},
|
||||
{"scope de tipo inesperado no ve nada", "todos", 1, false},
|
||||
}
|
||||
|
||||
for _, cas := range casos {
|
||||
t.Run(cas.nombre, func(t *testing.T) {
|
||||
c := app.AcquireCtx(&fasthttp.RequestCtx{})
|
||||
defer app.ReleaseCtx(c)
|
||||
if cas.scope != nil {
|
||||
c.Locals(umindScopeKey, cas.scope)
|
||||
}
|
||||
if got := UmindPuedeVerTenant(c, cas.tenantID); got != cas.esperado {
|
||||
t.Errorf("UmindPuedeVerTenant(tenant %d) = %v, esperaba %v", cas.tenantID, got, cas.esperado)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Un scope nil (staff) y uno vacío (cliente sin tenants) NO deben confundirse:
|
||||
// tratar el vacío como staff le daría acceso total a un cliente sin tenants.
|
||||
func TestScopeVacioNoEsStaff(t *testing.T) {
|
||||
app := fiber.New()
|
||||
|
||||
c := app.AcquireCtx(&fasthttp.RequestCtx{})
|
||||
defer app.ReleaseCtx(c)
|
||||
c.Locals(umindScopeKey, []uint{})
|
||||
if _, esStaff := UmindScopeDe(c); esStaff {
|
||||
t.Fatal("un scope vacío se está tratando como staff: un cliente sin tenants vería todo")
|
||||
}
|
||||
|
||||
c2 := app.AcquireCtx(&fasthttp.RequestCtx{})
|
||||
defer app.ReleaseCtx(c2)
|
||||
c2.Locals(umindScopeKey, []uint(nil))
|
||||
if _, esStaff := UmindScopeDe(c2); !esStaff {
|
||||
t.Fatal("un scope nil debería ser staff")
|
||||
}
|
||||
}
|
||||
@@ -57,4 +57,17 @@ func PortalRoutes(app fiber.Router) {
|
||||
portal.Post("/mi-perfil/telegram-init", controllers.PortalTelegramInit)
|
||||
portal.Get("/mi-perfil/telegram-status", controllers.PortalTelegramStatus)
|
||||
portal.Post("/mi-perfil/telegram-validar", controllers.PortalTelegramValidar)
|
||||
|
||||
// ─── uMind Studio para el cliente ──────────────────────────────────────────
|
||||
// Los MISMOS handlers que usa el staff, pero con UmindScopePortal: el
|
||||
// cliente solo alcanza los tenants de sus Cliente. Sin middleware de
|
||||
// escritura extra — administrar lo suyo es justamente el punto.
|
||||
RegistrarRutasUmind(portal, middlewares.UmindScopePortal, nil)
|
||||
|
||||
// El SPA (mismo build que /orchestrator) con fallback para vue-router.
|
||||
studioFallback := func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./public/orchestrator/index.html")
|
||||
}
|
||||
portal.Get("/studio", studioFallback)
|
||||
portal.Get("/studio/*", studioFallback)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/rest/controllers"
|
||||
)
|
||||
|
||||
// RegistrarRutasUmind monta los mismos handlers de uMind bajo dos prefijos:
|
||||
// /app/umind para el staff y /portal/umind para el cliente. Una sola
|
||||
// implementación, dos historias de autenticación — duplicar los handlers sería
|
||||
// duplicar también las chances de que uno se olvide un chequeo de alcance.
|
||||
//
|
||||
// scope decide QUÉ tenants ve el que llama (UmindScopeStaff/UmindScopePortal).
|
||||
// escritura middleware extra para POST/PUT/DELETE (SoloAdmin del lado staff;
|
||||
// nil del lado del cliente, que ya está acotado por su scope).
|
||||
//
|
||||
// El CRUD de tenants NO se registra acá a propósito: crear/borrar tenants y
|
||||
// asignarles cliente y plan es del staff, el cliente solo administra lo que
|
||||
// hay adentro.
|
||||
func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Handler) {
|
||||
// Concatena el scope con el middleware de escritura, si lo hay. El scope
|
||||
// va SIEMPRE, también en las lecturas.
|
||||
w := func(h ...fiber.Handler) []fiber.Handler {
|
||||
out := []fiber.Handler{scope}
|
||||
if escritura != nil {
|
||||
out = append(out, escritura)
|
||||
}
|
||||
return append(out, h...)
|
||||
}
|
||||
r := func(h ...fiber.Handler) []fiber.Handler {
|
||||
return append([]fiber.Handler{scope}, h...)
|
||||
}
|
||||
|
||||
g.Get("/umind/tenants", r(controllers.GetUmindTenants)...)
|
||||
g.Get("/umind/agentes", r(controllers.GetUmindAgentesHandler)...)
|
||||
g.Post("/umind/agentes", w(controllers.CreateUmindAgenteHandler)...)
|
||||
g.Put("/umind/agentes/:id", w(controllers.UpdateUmindAgenteHandler)...)
|
||||
g.Delete("/umind/agentes/:id", w(controllers.DeleteUmindAgenteHandler)...)
|
||||
|
||||
g.Get("/umind/documentos", r(controllers.GetUmindDocumentosHandler)...)
|
||||
g.Post("/umind/documentos", w(controllers.CreateUmindDocumentoHandler)...)
|
||||
g.Delete("/umind/documentos/:id", w(controllers.DeleteUmindDocumentoHandler)...)
|
||||
|
||||
g.Get("/umind/sesiones", r(controllers.GetUmindSesionesHandler)...)
|
||||
g.Get("/umind/historial", r(controllers.GetUmindHistorialHandler)...)
|
||||
|
||||
g.Get("/umind/tools", r(controllers.GetUmindHerramientasHandler)...)
|
||||
g.Post("/umind/tools", w(controllers.CreateUmindHerramientaHandler)...)
|
||||
g.Put("/umind/tools/:id", w(controllers.UpdateUmindHerramientaHandler)...)
|
||||
g.Delete("/umind/tools/:id", w(controllers.DeleteUmindHerramientaHandler)...)
|
||||
|
||||
g.Get("/umind/canales", r(controllers.GetUmindCanalesHandler)...)
|
||||
g.Post("/umind/canales", w(controllers.CreateUmindCanalHandler)...)
|
||||
g.Put("/umind/canales/:id", w(controllers.UpdateUmindCanalHandler)...)
|
||||
g.Delete("/umind/canales/:id", w(controllers.DeleteUmindCanalHandler)...)
|
||||
|
||||
g.Post("/umind/chat", r(controllers.UmindChatPruebaHandler)...)
|
||||
|
||||
g.Get("/umind/conexiones", r(controllers.GetUmindConexionesHandler)...)
|
||||
g.Get("/umind/conexiones/conectar", w(controllers.UmindConectarHandler)...)
|
||||
g.Delete("/umind/conexiones/:id", w(controllers.DeleteUmindConexionHandler)...)
|
||||
|
||||
g.Get("/umind/eventos", r(controllers.GetUmindEventosHandler)...)
|
||||
g.Get("/umind/uso", r(controllers.GetUmindUsoHandler)...)
|
||||
g.Get("/umind/ai-configs", r(controllers.GetUmindAiConfigsHandler)...)
|
||||
}
|
||||
+8
-28
@@ -356,34 +356,14 @@ func UserRoutes(app fiber.Router) {
|
||||
// desde qué dominios se puede llamar al widget, así que crear/editar es
|
||||
// solo para administradores.
|
||||
protected.Get("/umind", middlewares.MenuMiddleware, controllers.UmindIndex)
|
||||
protected.Get("/umind/tenants", controllers.GetUmindTenants)
|
||||
protected.Post("/umind/tenants", middlewares.SoloAdmin, controllers.CreateUmindTenantHandler)
|
||||
protected.Put("/umind/tenants/:id", middlewares.SoloAdmin, controllers.UpdateUmindTenantHandler)
|
||||
protected.Delete("/umind/tenants/:id", middlewares.SoloAdmin, controllers.DeleteUmindTenantHandler)
|
||||
protected.Get("/umind/agentes", controllers.GetUmindAgentesHandler)
|
||||
protected.Post("/umind/agentes", middlewares.SoloAdmin, controllers.CreateUmindAgenteHandler)
|
||||
protected.Put("/umind/agentes/:id", middlewares.SoloAdmin, controllers.UpdateUmindAgenteHandler)
|
||||
protected.Delete("/umind/agentes/:id", middlewares.SoloAdmin, controllers.DeleteUmindAgenteHandler)
|
||||
protected.Get("/umind/documentos", controllers.GetUmindDocumentosHandler)
|
||||
protected.Post("/umind/documentos", middlewares.SoloAdmin, controllers.CreateUmindDocumentoHandler)
|
||||
protected.Delete("/umind/documentos/:id", middlewares.SoloAdmin, controllers.DeleteUmindDocumentoHandler)
|
||||
protected.Get("/umind/sesiones", controllers.GetUmindSesionesHandler)
|
||||
protected.Get("/umind/historial", controllers.GetUmindHistorialHandler)
|
||||
protected.Get("/umind/tools", controllers.GetUmindHerramientasHandler)
|
||||
protected.Post("/umind/tools", middlewares.SoloAdmin, controllers.CreateUmindHerramientaHandler)
|
||||
protected.Put("/umind/tools/:id", middlewares.SoloAdmin, controllers.UpdateUmindHerramientaHandler)
|
||||
protected.Delete("/umind/tools/:id", middlewares.SoloAdmin, controllers.DeleteUmindHerramientaHandler)
|
||||
protected.Get("/umind/canales", controllers.GetUmindCanalesHandler)
|
||||
protected.Post("/umind/canales", middlewares.SoloAdmin, controllers.CreateUmindCanalHandler)
|
||||
protected.Put("/umind/canales/:id", middlewares.SoloAdmin, controllers.UpdateUmindCanalHandler)
|
||||
protected.Delete("/umind/canales/:id", middlewares.SoloAdmin, controllers.DeleteUmindCanalHandler)
|
||||
protected.Post("/umind/chat", controllers.UmindChatPruebaHandler)
|
||||
protected.Get("/umind/conexiones", controllers.GetUmindConexionesHandler)
|
||||
protected.Get("/umind/conexiones/conectar", middlewares.SoloAdmin, controllers.UmindConectarHandler)
|
||||
protected.Get("/umind/conexiones/callback/:proveedor", controllers.UmindOAuthCallbackHandler)
|
||||
protected.Delete("/umind/conexiones/:id", middlewares.SoloAdmin, controllers.DeleteUmindConexionHandler)
|
||||
protected.Get("/umind/eventos", controllers.GetUmindEventosHandler)
|
||||
protected.Get("/umind/uso", controllers.GetUmindUsoHandler)
|
||||
// Las escrituras siguen siendo SoloAdmin del lado del staff; el alcance
|
||||
// (qué tenants ve cada quien) lo decide el middleware de scope, que es el
|
||||
// mismo contrato para /app/umind y /portal/umind.
|
||||
RegistrarRutasUmind(protected, middlewares.UmindScopeStaff, middlewares.SoloAdmin)
|
||||
protected.Post("/umind/tenants", middlewares.SoloAdmin, middlewares.UmindScopeStaff, controllers.CreateUmindTenantHandler)
|
||||
protected.Put("/umind/tenants/:id", middlewares.SoloAdmin, middlewares.UmindScopeStaff, controllers.UpdateUmindTenantHandler)
|
||||
protected.Delete("/umind/tenants/:id", middlewares.SoloAdmin, middlewares.UmindScopeStaff, controllers.DeleteUmindTenantHandler)
|
||||
protected.Get("/umind/conexiones/callback/:proveedor", middlewares.UmindScopeStaff, controllers.UmindOAuthCallbackHandler)
|
||||
// Planes: definen el límite de agentes y los precios por consumo. Solo staff.
|
||||
protected.Get("/umind-planes", middlewares.MenuMiddleware, controllers.UmindPlanesPage)
|
||||
protected.Get("/umind-planes/list", controllers.GetUmindPlanesHandler)
|
||||
|
||||
Reference in New Issue
Block a user