feat(ai-config): cifra la API key en reposo y la acota por tenant

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 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 11:36:47 -05:00
co-authored by Claude Sonnet 5
parent c69b904b03
commit 96cd24f17d
9 changed files with 153 additions and 25 deletions
+6 -5
View File
@@ -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}
+3 -3
View File
@@ -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,
})