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:
co-authored by
Claude Sonnet 5
parent
c69b904b03
commit
96cd24f17d
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user