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:
Lizandro Guarnizo
2026-08-13 11:51:12 -05:00
co-authored by Claude Sonnet 5
parent 08265510ea
commit 5b78f6677c
19 changed files with 575 additions and 107 deletions
+79 -3
View File
@@ -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)
+8 -2
View File
@@ -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()})
}
+140
View File
@@ -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})
}
+80
View File
@@ -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
}
+63
View File
@@ -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")
}
}
+13
View File
@@ -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)
}
+66
View File
@@ -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
View File
@@ -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)