Changeset 0.9.4 (#54)
This commit is contained in:
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
@@ -19,10 +21,11 @@ import (
|
||||
|
||||
type AdminHandler struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
func NewAdminHandler(s store.Stores) *AdminHandler {
|
||||
return &AdminHandler{stores: s}
|
||||
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver) *AdminHandler {
|
||||
return &AdminHandler{stores: s, vault: vault}
|
||||
}
|
||||
|
||||
// ── User Management ─────────────────────────
|
||||
@@ -254,7 +257,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
||||
for i, cfg := range cfgs {
|
||||
out[i] = configWithKey{
|
||||
ProviderConfig: cfg,
|
||||
HasKey: cfg.APIKeyEnc != "",
|
||||
HasKey: cfg.HasKey(),
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"configs": out})
|
||||
@@ -291,16 +294,32 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
||||
Name: req.Name,
|
||||
Provider: req.Provider,
|
||||
Endpoint: req.Endpoint,
|
||||
APIKeyEnc: req.APIKey, // TODO: encrypt
|
||||
ModelDefault: req.ModelDefault,
|
||||
Config: models.JSONMap(req.Config),
|
||||
Headers: models.JSONMap(req.Headers),
|
||||
Settings: models.JSONMap(req.Settings),
|
||||
Scope: models.ScopeGlobal,
|
||||
KeyScope: models.ScopeGlobal,
|
||||
IsActive: true,
|
||||
IsPrivate: req.IsPrivate,
|
||||
}
|
||||
|
||||
// Encrypt the API key
|
||||
if req.APIKey != "" {
|
||||
if h.vault != nil {
|
||||
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
cfg.APIKeyEnc = enc
|
||||
cfg.KeyNonce = nonce
|
||||
} else {
|
||||
// No vault configured — store raw bytes (unencrypted fallback)
|
||||
cfg.APIKeyEnc = []byte(req.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
|
||||
return
|
||||
@@ -319,7 +338,7 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
||||
"settings": cfg.Settings,
|
||||
"is_active": cfg.IsActive,
|
||||
"is_private": cfg.IsPrivate,
|
||||
"has_key": cfg.APIKeyEnc != "",
|
||||
"has_key": cfg.HasKey(),
|
||||
"created_at": cfg.CreatedAt,
|
||||
"updated_at": cfg.UpdatedAt,
|
||||
})
|
||||
@@ -339,9 +358,19 @@ func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
patch := req.ProviderConfigPatch
|
||||
// Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding)
|
||||
// Transfer api_key → encrypted fields
|
||||
if req.APIKey != nil && *req.APIKey != "" {
|
||||
patch.APIKeyEnc = req.APIKey // TODO: encrypt
|
||||
if h.vault != nil {
|
||||
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
patch.APIKeyEnc = enc
|
||||
patch.KeyNonce = nonce
|
||||
} else {
|
||||
patch.APIKeyEnc = []byte(*req.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
|
||||
@@ -446,7 +475,21 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
|
||||
|
||||
// fetchModelsForProvider fetches and syncs models for a single provider config.
|
||||
func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) {
|
||||
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg)
|
||||
// Decrypt the API key for the provider API call
|
||||
apiKey := ""
|
||||
if len(cfg.APIKeyEnc) > 0 {
|
||||
if h.vault != nil {
|
||||
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// No vault — key stored as raw bytes (unencrypted fallback)
|
||||
apiKey = string(cfg.APIKeyEnc)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
@@ -13,10 +14,11 @@ import (
|
||||
// ProviderConfigHandler handles user-facing provider config endpoints.
|
||||
type ProviderConfigHandler struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
func NewProviderConfigHandler(s store.Stores) *ProviderConfigHandler {
|
||||
return &ProviderConfigHandler{stores: s}
|
||||
func NewProviderConfigHandler(s store.Stores, vault *crypto.KeyResolver) *ProviderConfigHandler {
|
||||
return &ProviderConfigHandler{stores: s, vault: vault}
|
||||
}
|
||||
|
||||
// ListConfigs returns configs accessible to the user (global + personal + team).
|
||||
@@ -52,7 +54,7 @@ func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
|
||||
Name: cfg.Name,
|
||||
Provider: cfg.Provider,
|
||||
Endpoint: cfg.Endpoint,
|
||||
HasKey: cfg.APIKeyEnc != "",
|
||||
HasKey: cfg.HasKey(),
|
||||
ModelDefault: cfg.ModelDefault,
|
||||
Scope: cfg.Scope,
|
||||
IsActive: cfg.IsActive,
|
||||
@@ -111,13 +113,32 @@ func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
|
||||
Name: req.Name,
|
||||
Provider: req.Provider,
|
||||
Endpoint: req.Endpoint,
|
||||
APIKeyEnc: req.APIKey, // TODO: encrypt
|
||||
ModelDefault: req.ModelDefault,
|
||||
Scope: models.ScopePersonal,
|
||||
KeyScope: models.ScopePersonal,
|
||||
OwnerID: &userID,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
// Encrypt the API key with user's UEK (personal scope)
|
||||
if req.APIKey != "" {
|
||||
if h.vault != nil {
|
||||
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "personal", userID)
|
||||
if err != nil {
|
||||
if err == crypto.ErrVaultLocked {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
cfg.APIKeyEnc = enc
|
||||
cfg.KeyNonce = nonce
|
||||
} else {
|
||||
cfg.APIKeyEnc = []byte(req.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
|
||||
return
|
||||
@@ -133,7 +154,7 @@ func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
|
||||
"scope": cfg.Scope,
|
||||
}
|
||||
|
||||
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg)
|
||||
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, req.APIKey)
|
||||
if err != nil {
|
||||
// Provider created successfully but model fetch failed.
|
||||
// Return 201 (provider exists) with a warning so the frontend can show it.
|
||||
@@ -169,9 +190,23 @@ func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
patch := req.ProviderConfigPatch
|
||||
// Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding)
|
||||
// Transfer api_key → encrypted fields
|
||||
if req.APIKey != nil && *req.APIKey != "" {
|
||||
patch.APIKeyEnc = req.APIKey // TODO: encrypt
|
||||
if h.vault != nil {
|
||||
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "personal", userID)
|
||||
if err != nil {
|
||||
if err == crypto.ErrVaultLocked {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
patch.APIKeyEnc = enc
|
||||
patch.KeyNonce = nonce
|
||||
} else {
|
||||
patch.APIKeyEnc = []byte(*req.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
|
||||
@@ -228,7 +263,21 @@ func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg)
|
||||
// Decrypt the API key for the provider API call
|
||||
apiKey := ""
|
||||
if len(cfg.APIKeyEnc) > 0 {
|
||||
if h.vault != nil {
|
||||
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
apiKey = string(cfg.APIKeyEnc)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
|
||||
return
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
@@ -32,12 +34,13 @@ type Claims struct {
|
||||
}
|
||||
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
stores store.Stores
|
||||
cfg *config.Config
|
||||
stores store.Stores
|
||||
uekCache *crypto.UEKCache
|
||||
}
|
||||
|
||||
func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler {
|
||||
return &AuthHandler{cfg: cfg, stores: s}
|
||||
func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache) *AuthHandler {
|
||||
return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
@@ -90,6 +93,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate and store the User Encryption Key (vault)
|
||||
if err := h.initVault(c.Request.Context(), user.ID, req.Password); err != nil {
|
||||
log.Printf("⚠ Failed to init vault for user %s: %v", user.ID, err)
|
||||
// Non-fatal: user can still use the app, vault will init on next login
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Account created but requires admin approval",
|
||||
@@ -133,6 +142,9 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Vault: unwrap UEK into session cache (or init if first login post-migration)
|
||||
h.unlockVault(c.Request.Context(), user, req.Password)
|
||||
|
||||
h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID)
|
||||
|
||||
tokens, err := h.generateTokens(user)
|
||||
@@ -189,6 +201,11 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||||
}
|
||||
|
||||
// Evict UEK from session cache
|
||||
if userID, exists := c.Get("user_id"); exists {
|
||||
h.uekCache.Evict(userID.(string))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
@@ -239,6 +256,85 @@ func hashToken(token string) string {
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// ── Vault Lifecycle ─────────────────────────
|
||||
|
||||
// initVault generates a UEK, wraps it with the user's password, and stores
|
||||
// the encrypted UEK + salt + nonce on the user record. Called on registration
|
||||
// and on first login for pre-migration users.
|
||||
func (h *AuthHandler) initVault(ctx context.Context, userID, password string) error {
|
||||
if h.uekCache == nil {
|
||||
return nil // Vault not configured (e.g. tests)
|
||||
}
|
||||
|
||||
uek, err := crypto.GenerateUEK()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
salt, err := crypto.GenerateSalt()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||||
encryptedUEK, nonce, err := crypto.WrapUEK(uek, pdk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = database.DB.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true
|
||||
WHERE id = $4
|
||||
`, encryptedUEK, salt, nonce, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache the UEK for the session
|
||||
h.uekCache.Store(userID, uek)
|
||||
log.Printf(" 🔐 Vault initialized for user %s", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// unlockVault unwraps the UEK on login and caches it. If the user doesn't
|
||||
// have a vault yet (pre-migration account), initializes one.
|
||||
func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, password string) {
|
||||
if h.uekCache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var vaultSet bool
|
||||
var encryptedUEK, salt, nonce []byte
|
||||
|
||||
err := database.DB.QueryRowContext(ctx, `
|
||||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||||
FROM users WHERE id = $1
|
||||
`, user.ID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !vaultSet {
|
||||
// Pre-migration user: initialize vault on first login
|
||||
if err := h.initVault(ctx, user.ID, password); err != nil {
|
||||
log.Printf("⚠ Vault init on login failed for user %s: %v", user.ID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Derive PDK from password and unwrap UEK
|
||||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||||
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Vault unlock failed for user %s: %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.uekCache.Store(user.ID, uek)
|
||||
}
|
||||
|
||||
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
|
||||
func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
||||
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
||||
|
||||
@@ -26,7 +26,7 @@ func testConfig() *config.Config {
|
||||
|
||||
// testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests).
|
||||
func testAuthHandler() *AuthHandler {
|
||||
return NewAuthHandler(testConfig(), store.Stores{})
|
||||
return NewAuthHandler(testConfig(), store.Stores{}, nil)
|
||||
}
|
||||
|
||||
// ── JWT Token Tests ─────────────────────────
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
@@ -33,11 +34,13 @@ type completionRequest struct {
|
||||
}
|
||||
|
||||
// CompletionHandler proxies LLM requests through the backend.
|
||||
type CompletionHandler struct{}
|
||||
type CompletionHandler struct{
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
// NewCompletionHandler creates a new handler.
|
||||
func NewCompletionHandler() *CompletionHandler {
|
||||
return &CompletionHandler{}
|
||||
func NewCompletionHandler(vault *crypto.KeyResolver) *CompletionHandler {
|
||||
return &CompletionHandler{vault: vault}
|
||||
}
|
||||
|
||||
// ── Chat Completion ─────────────────────────
|
||||
@@ -387,16 +390,20 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
|
||||
// Load the config — allow personal, global, OR team configs the user belongs to
|
||||
var providerID, endpoint string
|
||||
var apiKey, modelDefault *string
|
||||
var modelDefault *string
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var customHeadersJSON, providerSettingsJSON []byte
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT provider, endpoint, api_key_enc, model_default, headers, settings
|
||||
SELECT provider, endpoint, api_key_enc, key_nonce, key_scope,
|
||||
model_default, headers, settings
|
||||
FROM provider_configs
|
||||
WHERE id = $1 AND is_active = true
|
||||
AND (scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = $2)
|
||||
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
|
||||
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||
`, configID, userID).Scan(&providerID, &endpoint, &apiKeyEnc, &keyNonce, &keyScope,
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("API config not found or not accessible")
|
||||
@@ -414,9 +421,22 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no model specified and no default model in config")
|
||||
}
|
||||
|
||||
// Decrypt API key using the appropriate tier
|
||||
key := ""
|
||||
if apiKey != nil {
|
||||
key = *apiKey
|
||||
if len(apiKeyEnc) > 0 {
|
||||
if h.vault != nil {
|
||||
var err error
|
||||
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID)
|
||||
if err != nil {
|
||||
if err == crypto.ErrVaultLocked {
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("personal vault is locked — please log in again")
|
||||
}
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("failed to decrypt API key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// No vault — key stored as raw bytes (unencrypted fallback)
|
||||
key = string(apiKeyEnc)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse custom headers
|
||||
|
||||
@@ -46,13 +46,13 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
// Auth (unprotected)
|
||||
auth := NewAuthHandler(cfg, stores)
|
||||
auth := NewAuthHandler(cfg, stores, nil)
|
||||
authGroup := api.Group("/auth")
|
||||
authGroup.POST("/register", auth.Register)
|
||||
authGroup.POST("/login", auth.Login)
|
||||
|
||||
// Public settings
|
||||
adm := NewAdminHandler(stores)
|
||||
adm := NewAdminHandler(stores, nil)
|
||||
api.GET("/settings/public", adm.PublicSettings)
|
||||
|
||||
// Protected routes
|
||||
@@ -70,7 +70,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
|
||||
|
||||
// User providers
|
||||
provCfg := NewProviderConfigHandler(stores)
|
||||
provCfg := NewProviderConfigHandler(stores, nil)
|
||||
protected.GET("/api-configs", provCfg.ListConfigs)
|
||||
protected.POST("/api-configs", provCfg.CreateConfig)
|
||||
protected.GET("/api-configs/:id", provCfg.GetConfig)
|
||||
@@ -80,7 +80,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
||||
|
||||
// Team self-service (same route group as production)
|
||||
teams := NewTeamHandler()
|
||||
teams := NewTeamHandler(nil)
|
||||
protected.GET("/teams/mine", teams.MyTeams)
|
||||
|
||||
teamScoped := protected.Group("/teams/:teamId")
|
||||
@@ -116,7 +116,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
|
||||
// Completions
|
||||
completions := NewCompletionHandler()
|
||||
completions := NewCompletionHandler(nil)
|
||||
protected.POST("/chat/completions", completions.Complete)
|
||||
|
||||
// Admin routes
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
@@ -54,11 +55,13 @@ type cursorRequest struct {
|
||||
}
|
||||
|
||||
// MessageHandler holds dependencies for message endpoints.
|
||||
type MessageHandler struct{}
|
||||
type MessageHandler struct{
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
// NewMessageHandler creates a new message handler.
|
||||
func NewMessageHandler() *MessageHandler {
|
||||
return &MessageHandler{}
|
||||
func NewMessageHandler(vault *crypto.KeyResolver) *MessageHandler {
|
||||
return &MessageHandler{vault: vault}
|
||||
}
|
||||
|
||||
// ── List Messages (flat, all branches) ──────
|
||||
@@ -353,7 +356,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
// ── Resolve model + provider ──
|
||||
|
||||
comp := NewCompletionHandler()
|
||||
comp := NewCompletionHandler(h.vault)
|
||||
|
||||
var presetSystemPrompt string
|
||||
model := req.Model
|
||||
|
||||
@@ -20,7 +20,8 @@ type syncResult struct {
|
||||
|
||||
// syncProviderModels fetches models from a provider's API and syncs them into the catalog.
|
||||
// New models default to 'disabled' visibility (admin must explicitly enable for global providers).
|
||||
func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) {
|
||||
// apiKeyPlain is the decrypted API key — callers are responsible for decryption.
|
||||
func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) {
|
||||
prov, err := providers.Get(cfg.Provider)
|
||||
if err != nil {
|
||||
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
|
||||
@@ -28,7 +29,7 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
|
||||
|
||||
provCfg := providers.ProviderConfig{
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: cfg.APIKeyEnc,
|
||||
APIKey: apiKeyPlain,
|
||||
}
|
||||
|
||||
if cfg.Headers != nil {
|
||||
@@ -80,8 +81,8 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
|
||||
|
||||
// syncAndEnableProviderModels fetches models and auto-enables them all.
|
||||
// Used for personal (BYOK) providers — the user explicitly added this provider to use it.
|
||||
func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) {
|
||||
result, err := syncProviderModels(ctx, stores, cfg)
|
||||
func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) {
|
||||
result, err := syncProviderModels(ctx, stores, cfg, apiKeyPlain)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
@@ -50,13 +50,13 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
|
||||
configs := make([]teamProvider, 0)
|
||||
for rows.Next() {
|
||||
var p teamProvider
|
||||
var apiKeyEnc *string
|
||||
var apiKeyEnc []byte
|
||||
var configRaw string
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Provider, &p.Endpoint, &apiKeyEnc,
|
||||
&p.ModelDefault, &configRaw, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
p.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
|
||||
p.HasKey = len(apiKeyEnc) > 0
|
||||
p.Config = parseJSONBConfig(configRaw)
|
||||
configs = append(configs, p)
|
||||
}
|
||||
@@ -104,12 +104,29 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
||||
configJSON = string(b)
|
||||
}
|
||||
|
||||
// Encrypt the API key for team scope
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
if req.APIKey != "" {
|
||||
if h.vault != nil {
|
||||
var err error
|
||||
apiKeyEnc, keyNonce, err = h.vault.EncryptForScope(req.APIKey, "team", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
apiKeyEnc = []byte(req.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
var id string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, model_default, config, is_private)
|
||||
VALUES ('team', $1, $2, $3, $4, $5, $6, $7::jsonb, $8)
|
||||
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint,
|
||||
api_key_enc, key_nonce, key_scope, model_default, config, is_private)
|
||||
VALUES ('team', $1, $2, $3, $4, $5, $6, 'team', $7, $8::jsonb, $9)
|
||||
RETURNING id
|
||||
`, teamID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON, req.IsPrivate,
|
||||
`, teamID, req.Name, req.Provider, req.Endpoint,
|
||||
apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create team provider: %v", err)
|
||||
@@ -162,10 +179,24 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
args = append(args, *req.Endpoint)
|
||||
argN++
|
||||
}
|
||||
if req.APIKey != nil {
|
||||
query += ", api_key_enc = $" + strconv.Itoa(argN)
|
||||
args = append(args, *req.APIKey)
|
||||
argN++
|
||||
if req.APIKey != nil && *req.APIKey != "" {
|
||||
if h.vault != nil {
|
||||
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "team", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
query += ", api_key_enc = $" + strconv.Itoa(argN)
|
||||
args = append(args, enc)
|
||||
argN++
|
||||
query += ", key_nonce = $" + strconv.Itoa(argN)
|
||||
args = append(args, nonce)
|
||||
argN++
|
||||
} else {
|
||||
query += ", api_key_enc = $" + strconv.Itoa(argN)
|
||||
args = append(args, []byte(*req.APIKey))
|
||||
argN++
|
||||
}
|
||||
}
|
||||
if req.ModelDefault != nil {
|
||||
query += ", model_default = $" + strconv.Itoa(argN)
|
||||
@@ -229,13 +260,14 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
||||
providerID := c.Param("id")
|
||||
|
||||
var name, providerType, endpoint string
|
||||
var apiKey *string
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var headersJSON []byte
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT name, provider, endpoint, api_key_enc, headers
|
||||
SELECT name, provider, endpoint, api_key_enc, key_nonce, key_scope, headers
|
||||
FROM provider_configs
|
||||
WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true
|
||||
`, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKey, &headersJSON)
|
||||
`, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKeyEnc, &keyNonce, &keyScope, &headersJSON)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
@@ -248,8 +280,17 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
key := ""
|
||||
if apiKey != nil {
|
||||
key = *apiKey
|
||||
if len(apiKeyEnc) > 0 {
|
||||
if h.vault != nil {
|
||||
var err error
|
||||
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
key = string(apiKeyEnc)
|
||||
}
|
||||
}
|
||||
|
||||
var customHeaders map[string]string
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
@@ -40,9 +41,13 @@ type updateMemberRequest struct {
|
||||
|
||||
// ── Handler ─────────────────────────────────
|
||||
|
||||
type TeamHandler struct{}
|
||||
type TeamHandler struct{
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
func NewTeamHandler() *TeamHandler { return &TeamHandler{} }
|
||||
func NewTeamHandler(vault *crypto.KeyResolver) *TeamHandler {
|
||||
return &TeamHandler{vault: vault}
|
||||
}
|
||||
|
||||
// ── Admin: List All Teams ───────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user