fix(umind): la config de IA de un cliente podía atender tareas del sistema

Auditando el aislamiento apareció el agujero al revés del que se buscaba: no
un cliente leyendo datos de otro, sino la cuenta de IA de un cliente pagando
trabajo nuestro.

GetAiConfigForService recorre las configs activas y devuelve la primera sin
módulo asignado. Las configs de cliente no llevan módulo — ninguna lo lleva, es
parte del diseño — así que caían justo en ese fallback. Con un cliente que
hubiera conectado su cuenta, su clave terminaba clasificando correos de
soporte, importando plantillas o atendiendo la vCard. Ninguno de los dos se
enteraba: la respuesta llegaba igual y la factura le llegaba a él.

Todos los resolvedores globales filtran ahora tenant_id IS NULL. Un test lo
verifica sobre el código de cada uno, porque son consultas a base y acá no hay
una.

El de embeddings además no podía ser de cliente por otra razón: los vectores de
todos los agentes tienen que salir del mismo modelo o la similitud coseno entre
ellos no significa nada. Un cliente con su propio modelo de embeddings rompía
su propia búsqueda sin un solo error visible.

Del alcance entre clientes, que era lo que se auditaba: los 39 handlers de
uMind validan, y el CRUD de espacios y planes ni siquiera se monta en las rutas
del portal. Lo que faltaba era prueba: UmindScopeDe distingue "staff" de
"cliente sin espacios" por nil contra slice vacío, y esa diferencia no tenía
un solo test. Ahora la cubre uno que además falla si se invierte el fail-closed
de una ruta sin scope — probado inyectando las dos fugas.

Y dos cosas que quedaban colgando:

En /app/ai-config toda config de cliente se mostraba como "Global", que es
justo lo que no es. Ahora dice de qué espacio es, por nombre.

El consumo de una cuenta propia se registraba con el costo del plan. Se sigue
midiendo —el cliente quiere ver cuánto usa su asistente— pero con costo cero y
marcado como cuenta propia: cobrarlo también sería cobrar dos veces lo mismo.
En la pantalla de consumo aparece "va por tu cuenta de IA" en vez de un "$0"
que parecería un error. El OCR y la transcripción siguen costando: son
servicios nuestros, los use quien los use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-20 23:15:28 -05:00
co-authored by Claude Opus 5
parent 926bbde9fb
commit b125ceef95
11 changed files with 221 additions and 61 deletions
+6 -1
View File
@@ -174,7 +174,12 @@ watch(() => props.id, cargar, { immediate: true })
{{ numero(r.cantidad) }} {{ r.unidad }} · {{ numero(r.eventos) }} usos
</p>
</div>
<span class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span>
<!-- Costo cero con consumo real quiere decir cuenta propia: lo
paga su proveedor. Mostrar "$0" a secas parecería un error. -->
<span v-if="r.costo > 0" class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span>
<span v-else class="text-xs text-tenue text-right leading-tight">
va por tu<br />cuenta de IA
</span>
</div>
</div>
+19 -6
View File
@@ -189,7 +189,7 @@ func QuitarAgenteBotSalvo(id uint) {
// GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada.
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err)
}
if ai.TelegramConfigID == nil {
@@ -207,7 +207,7 @@ func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
func GetAgenteBotAiConfig() (*AiConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
return nil, fmt.Errorf("no hay agente configurado: %w", err)
}
return &ai, nil
@@ -220,7 +220,15 @@ func GetAgenteBotAiConfig() (*AiConfig, error) {
// 3. Cualquier config activa (último recurso)
func GetAiConfigForService(service string) (*AiConfig, error) {
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Order("id ASC").Find(&items).Error; err != nil {
// tenant_id IS NULL: las configs de un cliente son SUYAS y solo las usa su
// agente. Sin este filtro, la del cliente que no tiene módulo asignado
// —ninguna lo tiene— caía en el fallback global y terminaba atendiendo
// tareas nuestras: clasificar correos de soporte, importar plantillas, la
// vCard. Es decir, su cuenta pagando nuestro trabajo, sin que se entere
// ninguno de los dos.
if err := app.Http.Database.DB.
Where("is_active = ? AND tenant_id IS NULL", true).
Order("id ASC").Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
if len(items) == 0 {
@@ -276,7 +284,7 @@ func esConfigDeUsoEspecial(modulo string) bool {
// tiene fallback a la global y a cualquier activa.
func HayAiConfigParaModulo(modulo string) bool {
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil {
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return false
}
for i := range items {
@@ -296,7 +304,8 @@ func HayAiConfigParaModulo(modulo string) bool {
// transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic).
func GetWhisperConfig() (*AiConfig, error) {
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil {
// Solo configs del staff, por lo mismo que GetAiConfigForService.
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
for i := range items {
@@ -319,7 +328,11 @@ func GetWhisperConfig() (*AiConfig, error) {
// en vez de caer a cualquier config activa.
func GetUmindEmbeddingsConfig() (*AiConfig, error) {
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil {
// Global obligatoriamente: los vectores de todos los agentes tienen que
// salir del mismo modelo o la similitud coseno entre ellos no significa
// nada. Un cliente con su propio modelo de embeddings rompería su propia
// búsqueda sin ningún error visible.
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
for i := range items {
+46 -1
View File
@@ -1,6 +1,10 @@
package models
import "testing"
import (
"os"
"strings"
"testing"
)
// El fallback de GetAiConfigForService puede terminar usando una config que no
// declara el servicio pedido. Lo que no puede es agarrar una dedicada a
@@ -21,3 +25,44 @@ func TestConfigsDeUsoEspecialNoSirvenDeComodin(t *testing.T) {
}
}
}
// La consulta de todo resolvedor global tiene que excluir las configs de
// cliente. Sin ese filtro, la config que carga un cliente —que no lleva módulo,
// ninguna lo lleva— cae en el fallback y pasa a atender tareas nuestras:
// clasificar correos de soporte, importar plantillas, la vCard. Su cuenta
// pagando nuestro trabajo, y sin que se entere ninguno de los dos.
//
// Se verifica sobre el código porque son consultas a base: acá no hay una.
func TestLosResolvedoresGlobalesExcluyenConfigsDeCliente(t *testing.T) {
fuente, err := os.ReadFile("ai_config.go")
if err != nil {
t.Fatal(err)
}
texto := string(fuente)
// Toda función que resuelve una config para uso del sistema.
resolvedores := []string{
"func GetAiConfigForService(",
"func GetWhisperConfig(",
"func GetUmindEmbeddingsConfig(",
"func GetAgenteBotAiConfig(",
"func GetAgenteBotConfig(",
"func HayAiConfigParaModulo(",
}
for _, firma := range resolvedores {
i := strings.Index(texto, firma)
if i < 0 {
t.Errorf("no encontré %s — ¿se renombró?", firma)
continue
}
// El cuerpo hasta la próxima función de nivel superior.
resto := texto[i+len(firma):]
if j := strings.Index(resto, "\nfunc "); j > 0 {
resto = resto[:j]
}
if !strings.Contains(resto, "tenant_id IS NULL") {
t.Errorf("%s no filtra tenant_id IS NULL: puede devolver la config de un cliente para una tarea del sistema", firma)
}
}
}
+15
View File
@@ -150,6 +150,21 @@ type UmindDocumento struct {
AutoActualizar bool `json:"auto_actualizar" gorm:"column:auto_actualizar;default:false"`
}
// NombresDeTenantsUmind devuelve id → nombre para poder etiquetar cosas que
// solo guardan el id. Una consulta y no una por fila: son pocos y se usan para
// pintar una lista entera.
func NombresDeTenantsUmind() map[uint]string {
var filas []UmindTenant
out := map[uint]string{}
if err := app.Http.Database.DB.Select("id, nombre").Find(&filas).Error; err != nil {
return out
}
for _, f := range filas {
out[f.ID] = f.Nombre
}
return out
}
func (UmindDocumento) TableName() string { return "umind_documentos" }
func CreateUmindDocumento(d *UmindDocumento) error {
+30 -2
View File
@@ -33,6 +33,11 @@ type UmindUso struct {
Moneda string `json:"moneda" gorm:"column:moneda;size:3"`
// FacturadoAt null = pendiente de cobrar en el próximo ciclo.
FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"`
// CuentaPropia marca el consumo que salió por la cuenta de IA del propio
// cliente. Se sigue midiendo —quiere ver cuánto usa su asistente— pero con
// costo cero: ya se lo factura su proveedor, y cobrárselo también sería
// cobrar dos veces por lo mismo.
CuentaPropia bool `json:"cuenta_propia" gorm:"column:cuenta_propia;default:false;index"`
}
func (UmindUso) TableName() string { return "umind_uso" }
@@ -55,12 +60,22 @@ func RegistrarUsoUmind(agenteID uint, tipo string, cantidad float64, unidad stri
if plan != nil {
moneda = plan.Moneda
}
costo := costoDeUso(plan, tipo, cantidad)
// Si el agente corre sobre la cuenta de IA del propio cliente, el consumo
// se registra igual pero no se le cobra: su proveedor ya se lo factura.
// Solo aplica a los tokens de IA — el OCR y la transcripción son nuestros
// servicios, los use quien los use.
propia := tipo == UsoTipoIA && agenteUsaCuentaPropia(agente)
costo := 0.0
if !propia {
costo = costoDeUso(plan, tipo, cantidad)
}
uso := &UmindUso{
TenantID: agente.TenantID, AgenteID: agenteID,
Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
Costo: costo, Moneda: moneda,
Costo: costo, Moneda: moneda, CuentaPropia: propia,
}
if err := app.Http.Database.DB.Create(uso).Error; err != nil {
log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err)
@@ -204,3 +219,16 @@ func MarcarUsoFacturadoPorCliente(clienteID uint) {
log.Printf("[UMIND_USO] no se pudo marcar como facturado el consumo del cliente %d: %v", clienteID, err)
}
}
// agenteUsaCuentaPropia dice si el agente apunta a una config de IA cargada por
// el cliente (con tenant), en vez de a una nuestra.
func agenteUsaCuentaPropia(agente *UmindAgente) bool {
if agente == nil || agente.AiConfigID == nil {
return false
}
var cfg AiConfig
if err := GetAiConfigByID(*agente.AiConfigID, &cfg); err != nil {
return false
}
return cfg.TenantID != nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-YAZk1p08.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DjoN7J4s.css">
<script type="module" crossorigin src="/orchestrator/assets/index-DN4RTleh.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-Ds3rxNrq.css">
</head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la
+9 -2
View File
@@ -64,10 +64,17 @@
<tr class="hover:bg-gray-50 transition">
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
<td class="py-2 px-3">
<template x-if="!item.modulo">
<!-- Una config de cliente no tiene módulo, así que
antes se mostraba como "Global" — que es
justo lo que no es. -->
<template x-if="item.tenant_id">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-700"
x-text="'Cliente: ' + (item.tenant_nombre || ('espacio ' + item.tenant_id))"></span>
</template>
<template x-if="!item.modulo && !item.tenant_id">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-500">Global</span>
</template>
<template x-if="item.modulo">
<template x-if="item.modulo && !item.tenant_id">
<div class="flex flex-wrap gap-1">
<template x-for="m in item.modulo.split(',').filter(x => x.trim())" :key="m">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
+10
View File
@@ -50,7 +50,13 @@ func GetAiConfigs(c *fiber.Ctx) error {
Modulo string `json:"modulo"`
EsAgenteBot bool `json:"es_agente_bot"`
TelegramConfigID *uint `json:"telegram_config_id"`
TenantID *uint `json:"tenant_id"`
TenantNombre string `json:"tenant_nombre"`
}
// Los nombres de los espacios, en una consulta: sin esto la pantalla
// mostraría "tenant 7" y nadie sabe de quién es el 7.
nombresTenant := models.NombresDeTenantsUmind()
safeItems := make([]safe, len(items))
for i, it := range items {
// El hint sale de la clave en claro: sobre el ciphertext mostraría
@@ -72,6 +78,10 @@ func GetAiConfigs(c *fiber.Ctx) error {
Modulo: it.Modulo,
EsAgenteBot: it.EsAgenteBot,
TelegramConfigID: it.TelegramConfigID,
TenantID: it.TenantID,
}
if it.TenantID != nil {
safeItems[i].TenantNombre = nombresTenant[*it.TenantID]
}
}
+79 -42
View File
@@ -1,63 +1,100 @@
package middlewares
import (
"net/http/httptest"
"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()
// Este archivo prueba la única pieza que separa los datos de un cliente de los
// de otro. Un fallo acá no es un bug de pantalla: es el cliente A leyendo las
// conversaciones, el conocimiento y las claves del cliente B.
// Se prueba a través de una petición real en vez de fabricar un contexto a
// mano: es el mismo camino que recorre el middleware en producción.
func permiteVer(t *testing.T, scope interface{}, poner bool, tenantID uint) bool {
t.Helper()
app := fiber.New()
var permitido bool
app.Get("/x", func(c *fiber.Ctx) error {
if poner {
c.Locals(umindScopeKey, scope)
}
permitido = UmindPuedeVerTenant(c, tenantID)
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/x", nil)
if _, err := app.Test(req); err != nil {
t.Fatal(err)
}
return permitido
}
func scopeResuelto(t *testing.T, scope interface{}) ([]uint, bool) {
t.Helper()
app := fiber.New()
var ids []uint
var esStaff bool
app.Get("/x", func(c *fiber.Ctx) error {
c.Locals(umindScopeKey, scope)
ids, esStaff = UmindScopeDe(c)
return c.SendStatus(200)
})
if _, err := app.Test(httptest.NewRequest("GET", "/x", nil)); err != nil {
t.Fatal(err)
}
return ids, esStaff
}
func TestUnClienteNoVeElEspacioDeOtro(t *testing.T) {
casos := []struct {
nombre string
scope interface{} // lo que quedó en Locals
tenantID uint
esperado bool
scope interface{}
poner bool
tenant uint
permitir 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},
// El caso que importa: alcance sobre 1 y 7, pregunta por el 9.
{"espacio ajeno", []uint{1, 7}, true, 9, false},
{"espacio propio", []uint{1, 7}, true, 7, true},
{"otro espacio propio", []uint{1, 7}, true, 1, true},
// Un cliente sin espacios no ve absolutamente nada.
{"cliente sin espacios", []uint{}, true, 1, false},
// nil = staff, ve todo. Es la distinción más delicada del archivo:
// confundir "lista vacía" con nil le daría acceso total a un cliente.
{"staff", []uint(nil), true, 999, true},
// Ruta sin middleware de scope: fail-closed. Que se rompa visible en
// vez de servir los datos de todos por omisión.
{"sin scope en el contexto", nil, false, 1, false},
// Un valor de otro tipo en la clave tampoco puede abrir la puerta.
{"scope corrupto", "todos", true, 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)
}
})
for _, cs := range casos {
got := permiteVer(t, cs.scope, cs.poner, cs.tenant)
if got != cs.permitir {
t.Errorf("%s: UmindPuedeVerTenant(%d) = %v, esperaba %v", cs.nombre, cs.tenant, got, cs.permitir)
}
}
}
// 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")
// La diferencia entre nil y slice vacío es la que decide si alguien es staff.
// Si un cliente sin espacios quedara como nil, pasaría a ver todo.
func TestSliceVacioNoEsStaff(t *testing.T) {
ids, esStaff := scopeResuelto(t, []uint{})
if esStaff {
t.Error("un cliente sin espacios NO puede resolver como staff")
}
if len(ids) != 0 {
t.Errorf("esperaba lista vacía, dio %v", ids)
}
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")
if _, esStaff := scopeResuelto(t, []uint(nil)); !esStaff {
t.Error("nil tiene que resolver como staff: es como lo deja UmindScopeStaff")
}
}