package handlers import ( "context" "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "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" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/tools/search" ) type AdminHandler struct { stores store.Stores vault *crypto.KeyResolver uekCache *crypto.UEKCache } func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache) *AdminHandler { return &AdminHandler{stores: s, vault: vault, uekCache: uekCache} } // ── User Management ───────────────────────── func (h *AdminHandler) ListUsers(c *gin.Context) { opts := store.DefaultListOptions() // Accept both limit/offset and page/per_page conventions if limit, _ := strconv.Atoi(c.Query("limit")); limit > 0 { opts.Limit = limit } if offset, _ := strconv.Atoi(c.Query("offset")); offset > 0 { opts.Offset = offset } if perPage, _ := strconv.Atoi(c.Query("per_page")); perPage > 0 { opts.Limit = perPage } if page, _ := strconv.Atoi(c.Query("page")); page > 1 { opts.Offset = (page - 1) * opts.Limit } users, total, err := h.stores.Users.List(c.Request.Context(), opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) return } c.JSON(http.StatusOK, gin.H{"users": users, "total": total}) } func (h *AdminHandler) CreateUser(c *gin.Context) { var req struct { Username string `json:"username" binding:"required"` Email string `json:"email" binding:"required"` Password string `json:"password" binding:"required,min=8"` Role string `json:"role"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) role := models.UserRoleUser if req.Role == models.UserRoleAdmin { role = models.UserRoleAdmin } user := &models.User{ Username: strings.ToLower(req.Username), Email: strings.ToLower(req.Email), PasswordHash: string(hash), Role: role, IsActive: true, } if err := h.stores.Users.Create(c.Request.Context(), user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) return } h.auditLog(c, "user.create", "user", user.ID, nil) c.JSON(http.StatusCreated, user) } func (h *AdminHandler) UpdateUserRole(c *gin.Context) { id := c.Param("id") var req struct { Role string `json:"role" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"}) return } h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role}) c.JSON(http.StatusOK, gin.H{"message": "role updated"}) } func (h *AdminHandler) ToggleUserActive(c *gin.Context) { id := c.Param("id") var req struct { IsActive bool `json:"is_active"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Users.SetActive(c.Request.Context(), id, req.IsActive); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"}) return } h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive}) c.JSON(http.StatusOK, gin.H{"message": "user status updated"}) } func (h *AdminHandler) ResetPassword(c *gin.Context) { id := c.Param("id") var req struct { Password string `json:"password" binding:"required,min=8"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"password_hash": string(hash)}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"}) return } // Destroy vault — old UEK is unrecoverable with new password. // A fresh vault will be initialized on the user's next login. h.destroyVault(c, id) h.auditLog(c, "user.password_reset", "user", id, nil) c.JSON(http.StatusOK, gin.H{"message": "password reset"}) } // destroyVault nullifies a user's encrypted UEK and deletes their personal // provider keys. Called when an admin resets a user's password — the old // UEK cannot be recovered without the old password. func (h *AdminHandler) destroyVault(c *gin.Context, userID string) { if h.uekCache == nil { return } deleted := DestroyVaultDB(c.Request.Context(), userID) // Evict from session cache (if user is currently logged in) h.uekCache.Evict(userID) if deleted > 0 { log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", deleted, userID) } h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{ "reason": "admin_password_reset", }) } // ResetVault explicitly resets a user's vault without changing their password. // Clears vault columns, deletes personal provider configs, evicts UEK cache. // The user gets a fresh vault on their next login. // POST /api/v1/admin/users/:id/vault/reset func (h *AdminHandler) ResetVault(c *gin.Context) { userID := c.Param("id") // Verify user exists user, err := h.stores.Users.GetByID(c.Request.Context(), userID) if err != nil || user == nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } if h.uekCache == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "vault not configured"}) return } deleted := DestroyVaultDB(c.Request.Context(), userID) h.uekCache.Evict(userID) h.auditLog(c, "user.vault_reset", "user", userID, models.JSONMap{ "reason": "admin_manual_reset", "providers_deleted": deleted, }) log.Printf(" 🔐 Vault reset for user %s (%d personal provider(s) cleared)", userID, deleted) c.JSON(http.StatusOK, gin.H{ "message": "vault reset — user will get a fresh vault on next login", "providers_deleted": deleted, }) } func (h *AdminHandler) DeleteUser(c *gin.Context) { id := c.Param("id") if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) return } h.auditLog(c, "user.delete", "user", id, nil) c.JSON(http.StatusOK, gin.H{"message": "user deleted"}) } // ── Global Settings ───────────────────────── func (h *AdminHandler) ListGlobalSettings(c *gin.Context) { settings, err := h.stores.GlobalConfig.GetAll(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"}) return } // Also include policies policies, _ := h.stores.Policies.GetAll(c.Request.Context()) c.JSON(http.StatusOK, gin.H{"settings": settings, "policies": policies}) } func (h *AdminHandler) GetGlobalSetting(c *gin.Context) { key := c.Param("key") val, err := h.stores.GlobalConfig.Get(c.Request.Context(), key) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"}) return } c.JSON(http.StatusOK, gin.H{"key": key, "value": val}) } func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) { key := c.Param("key") userID, _ := c.Get("user_id") uid := userID.(string) var req struct { Value json.RawMessage `json:"value"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Determine if this is a policy (string value) or a global config (JSON value) var strVal string if err := json.Unmarshal(req.Value, &strVal); err == nil { // String value → try as policy first if _, ok := models.PolicyDefaults[key]; ok { if err := h.stores.Policies.Set(c.Request.Context(), key, strVal, uid); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy"}) return } h.auditLog(c, "policy.update", "policy", key, gin.H{"value": strVal}) c.JSON(http.StatusOK, gin.H{"message": "policy updated"}) return } } // JSON value → global config var jsonVal models.JSONMap json.Unmarshal(req.Value, &jsonVal) if err := h.stores.GlobalConfig.Set(c.Request.Context(), key, jsonVal, uid); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"}) return } h.auditLog(c, "settings.update", "global_settings", key, nil) // Live-apply hooks for settings that have runtime effects if key == "search_config" { applySearchConfig(jsonVal) } c.JSON(http.StatusOK, gin.H{"message": "setting updated"}) } // applySearchConfig updates the active search provider at runtime. func applySearchConfig(raw models.JSONMap) { b, err := json.Marshal(raw) if err != nil { log.Printf("⚠️ Failed to marshal search config: %v", err) return } var cfg search.Config if err := json.Unmarshal(b, &cfg); err != nil { log.Printf("⚠️ Failed to parse search config: %v", err) return } if err := search.ApplyConfig(cfg); err != nil { log.Printf("⚠️ Failed to apply search config: %v", err) } } func (h *AdminHandler) PublicSettings(c *gin.Context) { // Banner config, branding, etc. — safe subset for non-admin users banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner") branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding") systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt") policies, _ := h.stores.Policies.GetAll(c.Request.Context()) // Paste-to-file threshold (admin-configurable via storage.paste_to_file_chars, default 2000) pasteChars := 2000 if storageSettings, err := h.stores.GlobalConfig.Get(c.Request.Context(), "storage"); err == nil { if v, ok := storageSettings["paste_to_file_chars"]; ok { switch n := v.(type) { case float64: pasteChars = int(n) case int: pasteChars = n } } } // Only tell the user whether admin prompt exists — don't expose content hasAdminPrompt := false if content, ok := systemPrompt["content"].(string); ok && content != "" { hasAdminPrompt = true } c.JSON(http.StatusOK, gin.H{ "banner": banner, "branding": branding, "has_admin_prompt": hasAdminPrompt, "storage_configured": storageConfigured, "paste_to_file_chars": pasteChars, "policies": gin.H{ "allow_registration": policies["allow_registration"], "allow_user_byok": policies["allow_user_byok"], "allow_user_personas": policies["allow_user_personas"], }, }) } // ── Vault Status ──────────────────────────── func (h *AdminHandler) VaultStatus(c *gin.Context) { hasKey := h.vault != nil && h.vault.HasEnvKey() keyStr := "" if hasKey { keyStr = "set" // non-empty signals to VaultStatus that key is configured } status, err := crypto.VaultStatus(database.DB, keyStr) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"}) return } c.JSON(http.StatusOK, status) } // ── Provider Configs (Global) ─────────────── func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) return } // Redact API keys but expose has_key flag for UI type configWithKey struct { models.ProviderConfig HasKey bool `json:"has_key"` } out := make([]configWithKey, len(cfgs)) for i, cfg := range cfgs { out[i] = configWithKey{ ProviderConfig: cfg, HasKey: cfg.HasKey(), } } c.JSON(http.StatusOK, gin.H{"configs": out}) } func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { // Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-" // so ShouldBindJSON would silently drop the api_key field. var req struct { Name string `json:"name" binding:"required"` Provider string `json:"provider" binding:"required"` Endpoint string `json:"endpoint" binding:"required"` APIKey string `json:"api_key"` ModelDefault string `json:"model_default,omitempty"` Config map[string]interface{} `json:"config,omitempty"` Headers map[string]interface{} `json:"headers,omitempty"` Settings map[string]interface{} `json:"settings,omitempty"` IsPrivate bool `json:"is_private,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if _, err := providers.Get(req.Provider); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "unsupported provider: " + req.Provider, "supported_providers": providers.List(), }) return } cfg := &models.ProviderConfig{ Name: req.Name, Provider: req.Provider, Endpoint: req.Endpoint, 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 } h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider}) c.JSON(http.StatusCreated, gin.H{ "id": cfg.ID, "scope": cfg.Scope, "name": cfg.Name, "provider": cfg.Provider, "endpoint": cfg.Endpoint, "model_default": cfg.ModelDefault, "config": cfg.Config, "headers": cfg.Headers, "settings": cfg.Settings, "is_active": cfg.IsActive, "is_private": cfg.IsPrivate, "has_key": cfg.HasKey(), "created_at": cfg.CreatedAt, "updated_at": cfg.UpdatedAt, }) } func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) { id := c.Param("id") // Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-" var req struct { models.ProviderConfigPatch APIKey *string `json:"api_key,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } patch := req.ProviderConfigPatch // Transfer api_key → encrypted fields if req.APIKey != nil && *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 } 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"}) return } h.auditLog(c, "provider.update", "provider_config", id, nil) c.JSON(http.StatusOK, gin.H{"message": "config updated"}) } func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) { id := c.Param("id") // Delete associated catalog entries first h.stores.Catalog.DeleteForProvider(c.Request.Context(), id) if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"}) return } h.auditLog(c, "provider.delete", "provider_config", id, nil) c.JSON(http.StatusOK, gin.H{"message": "config deleted"}) } // ── Model Catalog ─────────────────────────── func (h *AdminHandler) ListModelConfigs(c *gin.Context) { entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) return } c.JSON(http.StatusOK, gin.H{"models": entries}) } func (h *AdminHandler) FetchModels(c *gin.Context) { var req struct { ProviderConfigID string `json:"provider_config_id"` } c.ShouldBindJSON(&req) // If no specific provider, fetch from ALL global providers if req.ProviderConfigID == "" { configs, err := h.stores.Providers.ListGlobal(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"}) return } if len(configs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"}) return } totalAdded, totalUpdated, totalFetched := 0, 0, 0 var errs []string for _, cfg := range configs { if !cfg.IsActive { continue } added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg) if err != nil { errs = append(errs, cfg.Provider+": "+err.Error()) continue } totalAdded += added totalUpdated += updated totalFetched += fetched } result := gin.H{ "message": "models synced", "added": totalAdded, "updated": totalUpdated, "total": totalFetched, } if len(errs) > 0 { result["errors"] = errs } c.JSON(http.StatusOK, result) return } // Single provider fetch cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"}) return } added, updated, fetched, err := h.fetchModelsForProvider(c, cfg) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": "models synced", "added": added, "updated": updated, "total": fetched, }) } // 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) { // 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 } h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{ "added": result.Added, "updated": result.Updated, "total": result.Total, }) return result.Added, result.Updated, result.Total, nil } func (h *AdminHandler) UpdateModelConfig(c *gin.Context) { id := c.Param("id") var req struct { Visibility *string `json:"visibility"` DisplayName *string `json:"display_name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Visibility != nil { if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"}) return } } c.JSON(http.StatusOK, gin.H{"message": "model updated"}) } func (h *AdminHandler) BulkUpdateModels(c *gin.Context) { var req struct { ProviderConfigID string `json:"provider_config_id"` Visibility string `json:"visibility" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var err error if req.ProviderConfigID != "" { err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility) } else { err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"}) return } c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"}) } func (h *AdminHandler) DeleteModelConfig(c *gin.Context) { id := c.Param("id") if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"}) return } c.JSON(http.StatusOK, gin.H{"message": "model deleted"}) } // ── Stats ─────────────────────────────────── func (h *AdminHandler) GetStats(c *gin.Context) { ctx := c.Request.Context() stats := gin.H{} var userCount, channelCount, messageCount int database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount) database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount) database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount) stats["users"] = userCount stats["channels"] = channelCount stats["messages"] = messageCount c.JSON(http.StatusOK, stats) } // ── Helpers ───────────────────────────────── // NOTE: ListAuditLog and ListAuditActions are in audit.go func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID string, metadata interface{}) { userID, _ := c.Get("user_id") uid, _ := userID.(string) var meta models.JSONMap if metadata != nil { b, _ := json.Marshal(metadata) json.Unmarshal(b, &meta) } entry := &models.AuditEntry{ ActorID: &uid, Action: action, ResourceType: resourceType, ResourceID: resourceID, Metadata: meta, IPAddress: c.ClientIP(), UserAgent: c.GetHeader("User-Agent"), } if err := h.stores.Audit.Log(context.Background(), entry); err != nil { log.Printf("audit log error: %v", err) } }