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)