From 96cd24f17db68e1cb94175408336679a11302199 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:36:47 -0500 Subject: [PATCH] feat(ai-config): cifra la API key en reposo y la acota por tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepara el terreno para que el cliente cargue su propia cuenta de IA sin que su clave quede en texto plano ni que el selector le muestre las de los demás. - ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se leen igual y quedan cifradas al primer guardado, sin script ni downtime. utils.Decrypt hace panic (no devuelve error) con entrada que no es un ciphertext válido, así que el fallback va sobre recover — eso mismo es el mecanismo de detección de "todavía está en claro". - Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram, Whisper, Query Runner, streaming de IA y Landing Generator. Un lector sin migrar mandaría el ciphertext como API key. - AiConfig gana TenantID (null = global del staff) y GetAiConfigSelectPorTenants para acotar el selector. - Test de los 4 casos del fallback, incluido hex válido que no descifra. Co-Authored-By: Claude Sonnet 5 --- pkg/models/ai_config.go | 60 ++++++++++++++++++++ pkg/models/ai_config_clave_test.go | 62 +++++++++++++++++++++ pkg/services/telegram_agent_service.go | 4 +- pkg/services/umind_embeddings_service.go | 2 +- pkg/services/whisper_service.go | 4 +- rest/controllers/ai_config_controller.go | 20 ++++--- rest/controllers/api/ia_controller.go | 11 ++-- rest/controllers/api/landing_controller.go | 6 +- rest/controllers/query_runner_controller.go | 9 +-- 9 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 pkg/models/ai_config_clave_test.go diff --git a/pkg/models/ai_config.go b/pkg/models/ai_config.go index 3f19085..82e25ff 100644 --- a/pkg/models/ai_config.go +++ b/pkg/models/ai_config.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/utils" "gorm.io/gorm" ) @@ -29,10 +30,52 @@ type AiConfig struct { // Solo debe haber una config activa como agente a la vez. EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"` TelegramConfigID *uint `gorm:"index" json:"telegram_config_id"` + // TenantID acota la config a un tenant de uMind: null = config global del + // staff (el comportamiento histórico). Sin esto, el selector de IA le + // mostraría a cada cliente las claves de todos los demás. + TenantID *uint `gorm:"index" json:"tenant_id"` } func (AiConfig) TableName() string { return "ai_configs" } +// ClaveEnClaro devuelve la API key lista para usar. Las filas guardadas antes +// de que se cifrara este campo están en texto plano y se devuelven tal cual; +// al volver a guardarlas quedan cifradas, así que el parque se migra solo sin +// script ni downtime. +// +// utils.Decrypt hace panic con entrada que no sea un ciphertext válido (no +// devuelve error), de ahí el recover: es el mecanismo de detección de "esto +// todavía está en texto plano". +func (c *AiConfig) ClaveEnClaro() string { + if c.ApiKey == "" || app.Http.Server.Key == "" { + return c.ApiKey + } + return descifrarOTalCual(c.ApiKey) +} + +func descifrarOTalCual(valor string) (out string) { + defer func() { + if recover() != nil { + out = valor + } + }() + claro := utils.Decrypt(valor, app.Http.Server.Key) + if claro == "" { + return valor + } + return claro +} + +// CifrarClaveAi cifra una API key para guardarla. Si no hay APP_KEY +// configurada devuelve el valor tal cual — preferible a romper el guardado en +// un entorno sin la clave, y ClaveEnClaro lo lee igual. +func CifrarClaveAi(clave string) string { + if clave == "" || app.Http.Server.Key == "" { + return clave + } + return utils.Encrypt(clave, app.Http.Server.Key) +} + func GetAllAiConfigs(limit, offset int, search string) ([]AiConfig, int64, error) { var items []AiConfig var total int64 @@ -111,6 +154,23 @@ func GetAiConfigSelect() ([]AiConfig, error) { return items, nil } +// GetAiConfigSelectPorTenants acota el selector a las configs propias de esos +// tenants más las globales del staff (tenant_id IS NULL), que son las que se +// ofrecen a todos. Sin este filtro el cliente vería las claves de los demás. +func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) { + var items []AiConfig + db := app.Http.Database.DB.Model(&AiConfig{}).Select("id, nombre, provider, tenant_id") + if len(tenantIDs) == 0 { + db = db.Where("tenant_id IS NULL") + } else { + db = db.Where("tenant_id IS NULL OR tenant_id IN ?", tenantIDs) + } + if err := db.Order("nombre ASC").Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + // GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada. func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) { var ai AiConfig diff --git a/pkg/models/ai_config_clave_test.go b/pkg/models/ai_config_clave_test.go new file mode 100644 index 0000000..5946373 --- /dev/null +++ b/pkg/models/ai_config_clave_test.go @@ -0,0 +1,62 @@ +package models + +import ( + "testing" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/config" + "github.com/sujit-baniya/fiber-boilerplate/utils" +) + +// El resto del proyecto inicializa app.Http en el arranque; en tests hay que +// hacerlo a mano antes de tocar cualquier cosa que lea la config. +func conAppKey(t *testing.T, key string) { + t.Helper() + anterior := app.Http + app.Http = &config.AppConfig{} + app.Http.Server.Key = key + t.Cleanup(func() { app.Http = anterior }) +} + +// La migración progresiva de AiConfig.ApiKey depende de que ClaveEnClaro +// distinga una clave cifrada de una que todavía está en texto plano. Si esto +// se rompe, el sistema empieza a mandar ciphertext como API key a OpenAI y +// todos los agentes dejan de responder. +func TestClaveEnClaroMigracionProgresiva(t *testing.T) { + conAppKey(t, "6368616e676520746869732070617373776f726420746f206120736563726574") // 32 bytes en hex + + casos := []struct { + nombre string + guardado string + esperado string + descripci string + }{ + {"vacío", "", "", "sin clave no hay nada que descifrar"}, + {"texto plano", "sk-proj-abc123", "sk-proj-abc123", "fila vieja sin cifrar, se devuelve tal cual"}, + {"cifrada", utils.Encrypt("sk-proj-abc123", app.Http.Server.Key), "sk-proj-abc123", "fila nueva, se descifra"}, + {"hex que no es ciphertext", "deadbeef", "deadbeef", "hex válido pero no descifrable: no debe romper"}, + } + + for _, cas := range casos { + t.Run(cas.nombre, func(t *testing.T) { + cfg := &AiConfig{ApiKey: cas.guardado} + if got := cfg.ClaveEnClaro(); got != cas.esperado { + t.Errorf("%s: ClaveEnClaro() = %q, esperaba %q", cas.descripci, got, cas.esperado) + } + }) + } +} + +// Sin APP_KEY el guardado no debe romperse: se guarda en claro y se lee en +// claro, que es exactamente el comportamiento previo a esta migración. +func TestCifrarClaveAiSinAppKey(t *testing.T) { + conAppKey(t, "") + + if got := CifrarClaveAi("sk-test"); got != "sk-test" { + t.Errorf("CifrarClaveAi sin APP_KEY = %q, esperaba pasarla tal cual", got) + } + cfg := &AiConfig{ApiKey: "sk-test"} + if got := cfg.ClaveEnClaro(); got != "sk-test" { + t.Errorf("ClaveEnClaro sin APP_KEY = %q, esperaba pasarla tal cual", got) + } +} diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index fd3c7d0..3286363 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -1314,7 +1314,7 @@ func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+ai.ApiKey) + req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro()) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 120 * time.Second} @@ -1461,7 +1461,7 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent if err != nil { return nil, err } - req.Header.Set("x-api-key", ai.ApiKey) + req.Header.Set("x-api-key", ai.ClaveEnClaro()) req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("Content-Type", "application/json") diff --git a/pkg/services/umind_embeddings_service.go b/pkg/services/umind_embeddings_service.go index a2c2190..79b5873 100644 --- a/pkg/services/umind_embeddings_service.go +++ b/pkg/services/umind_embeddings_service.go @@ -46,7 +46,7 @@ func GenerarEmbeddings(ai *models.AiConfig, textos []string) ([][]float32, error if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+ai.ApiKey) + req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro()) req.Header.Set("Content-Type", "application/json") resp, err := umindEmbeddingsHTTPClient.Do(req) diff --git a/pkg/services/whisper_service.go b/pkg/services/whisper_service.go index 83b61ed..adec529 100644 --- a/pkg/services/whisper_service.go +++ b/pkg/services/whisper_service.go @@ -26,7 +26,7 @@ func TranscribirAudio(ai *models.AiConfig, audioPath string) (string, error) { if ai == nil { return "", fmt.Errorf("no hay una configuración de Whisper activa: ve a /app/ai-config, crea o edita una y márcale el módulo 'Transcripción de audio (Whisper)'") } - if ai.ApiKey == "" { + if ai.ClaveEnClaro() == "" { return "", fmt.Errorf("la configuración de Whisper '%s' no tiene API key", ai.Nombre) } @@ -66,7 +66,7 @@ func TranscribirAudio(ai *models.AiConfig, audioPath string) (string, error) { if err != nil { return "", err } - req.Header.Set("Authorization", "Bearer "+ai.ApiKey) + req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro()) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := whisperHTTPClient.Do(req) diff --git a/rest/controllers/ai_config_controller.go b/rest/controllers/ai_config_controller.go index 1e9b410..cbf2f61 100644 --- a/rest/controllers/ai_config_controller.go +++ b/rest/controllers/ai_config_controller.go @@ -53,9 +53,12 @@ func GetAiConfigs(c *fiber.Ctx) error { } safeItems := make([]safe, len(items)) for i, it := range items { + // El hint sale de la clave en claro: sobre el ciphertext mostraría + // los últimos 4 del hex, que no le sirven a nadie para reconocerla. + clave := it.ClaveEnClaro() hint := "••••" - if len(it.ApiKey) > 4 { - hint = "••••" + it.ApiKey[len(it.ApiKey)-4:] + if len(clave) > 4 { + hint = "••••" + clave[len(clave)-4:] } safeItems[i] = safe{ ID: it.ID, @@ -111,7 +114,7 @@ func CreateAiConfigHandler(c *fiber.Ctx) error { item := models.AiConfig{ Nombre: strings.TrimSpace(req.Nombre), Provider: strings.ToLower(strings.TrimSpace(req.Provider)), - ApiKey: strings.TrimSpace(req.ApiKey), + ApiKey: models.CifrarClaveAi(strings.TrimSpace(req.ApiKey)), BaseURL: strings.TrimSpace(req.BaseURL), ModelName: strings.TrimSpace(req.ModelName), IsActive: req.IsActive, @@ -162,7 +165,7 @@ func UpdateAiConfigHandler(c *fiber.Ctx) error { "telegram_config_id": req.TelegramConfigID, } if strings.TrimSpace(req.ApiKey) != "" { - updates["api_key"] = strings.TrimSpace(req.ApiKey) + updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey)) } if err := models.UpdateAiConfig(uint(id), updates); err != nil { @@ -195,14 +198,15 @@ func TestAiConfigHandler(c *fiber.Ctx) error { var testURL string var req *http.Request + clave := item.ClaveEnClaro() switch item.Provider { case "ollama": base := strings.TrimSuffix(strings.TrimRight(item.BaseURL, "/"), "/v1") testURL = base + "/api/tags" req, _ = http.NewRequest("GET", testURL, nil) - if item.ApiKey != "" && item.ApiKey != "ollama" { - req.SetBasicAuth("ollama", item.ApiKey) + if clave != "" && clave != "ollama" { + req.SetBasicAuth("ollama", clave) } case "anthropic": // Anthropic no tiene /models; usamos /v1/models igual (devuelve 200 con lista) @@ -212,7 +216,7 @@ func TestAiConfigHandler(c *fiber.Ctx) error { } testURL = base + "/models" req, _ = http.NewRequest("GET", testURL, nil) - req.Header.Set("x-api-key", item.ApiKey) + req.Header.Set("x-api-key", clave) req.Header.Set("anthropic-version", "2023-06-01") default: // Mismo mapeo proveedor→URL que usa el chat/embeddings real @@ -225,7 +229,7 @@ func TestAiConfigHandler(c *fiber.Ctx) error { } testURL = base + "/models" req, _ = http.NewRequest("GET", testURL, nil) - req.Header.Set("Authorization", "Bearer "+item.ApiKey) + req.Header.Set("Authorization", "Bearer "+clave) } resp, err := client.Do(req) diff --git a/rest/controllers/api/ia_controller.go b/rest/controllers/api/ia_controller.go index 1090844..8ca446c 100644 --- a/rest/controllers/api/ia_controller.go +++ b/rest/controllers/api/ia_controller.go @@ -102,7 +102,7 @@ func GeneraTextoStream(c *fiber.Ctx) error { if useGemini { model := data.Model - endpoint = fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:streamGenerateContent?alt=sse&key=%s", model, config.ApiKey) + endpoint = fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:streamGenerateContent?alt=sse&key=%s", model, config.ClaveEnClaro()) gReq := geminiReq{ Contents: []geminiContent{ {Parts: []geminiPart{{Text: data.Prompt}}}, @@ -139,12 +139,13 @@ func GeneraTextoStream(c *fiber.Ctx) error { req.Header.Set("Content-Type", "application/json") // Auth + clave := config.ClaveEnClaro() if useGemini { // API key ya va en la URL - } else if provider == "ollama" && config.ApiKey != "" && config.ApiKey != "ollama" { - req.SetBasicAuth("ollama", config.ApiKey) - } else if config.ApiKey != "" { - req.Header.Set("Authorization", "Bearer "+config.ApiKey) + } else if provider == "ollama" && clave != "" && clave != "ollama" { + req.SetBasicAuth("ollama", clave) + } else if clave != "" { + req.Header.Set("Authorization", "Bearer "+clave) } client := &http.Client{Timeout: 120 * time.Second} diff --git a/rest/controllers/api/landing_controller.go b/rest/controllers/api/landing_controller.go index 151621f..bb6e4b7 100644 --- a/rest/controllers/api/landing_controller.go +++ b/rest/controllers/api/landing_controller.go @@ -269,8 +269,8 @@ func LandingCreatePayment(c *fiber.Ctx) error { callbackURL := req.CallbackURL result, err := services.CreateBoldPaymentLink(cfg, services.BoldPaymentLinkRequest{ - AmountType: "CLOSE", - Amount: services.BoldAmountField{Currency: "COP", TotalAmount: session.PriceCOP}, + AmountType: "CLOSE", + Amount: services.BoldAmountField{Currency: "COP", TotalAmount: session.PriceCOP}, Description: "Landing Page Profesional", Reference: reference, PayerEmail: session.UserEmail, @@ -379,7 +379,7 @@ func LandingGetAiConfig(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ "provider": config.Provider, - "api_key": config.ApiKey, + "api_key": config.ClaveEnClaro(), "base_url": config.BaseURL, "model_name": config.ModelName, }) diff --git a/rest/controllers/query_runner_controller.go b/rest/controllers/query_runner_controller.go index 21d5c6a..dec7ff2 100644 --- a/rest/controllers/query_runner_controller.go +++ b/rest/controllers/query_runner_controller.go @@ -802,17 +802,18 @@ SQL optimizado:`, sql) req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) req.Header.Set("Content-Type", "application/json") + clave := config.ClaveEnClaro() switch config.Provider { case "anthropic": - req.Header.Set(authHeader, config.ApiKey) + req.Header.Set(authHeader, clave) req.Header.Set("anthropic-version", "2023-06-01") case "ollama": // Sin auth para red interna; para URL pública el api_key es "usuario:token" - if config.ApiKey != "" && config.ApiKey != "ollama" { - req.SetBasicAuth("ollama", config.ApiKey) + if clave != "" && clave != "ollama" { + req.SetBasicAuth("ollama", clave) } default: - req.Header.Set(authHeader, "Bearer "+config.ApiKey) + req.Header.Set(authHeader, "Bearer "+clave) } resp, err := client.Do(req)