package handlers import ( "encoding/json" "log" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "switchboard-core/auth" "switchboard-core/crypto" "switchboard-core/database" "switchboard-core/models" "switchboard-core/storage" "switchboard-core/store" ) type AdminHandler struct { stores store.Stores vault *crypto.KeyResolver uekCache *crypto.UEKCache objStore storage.ObjectStore onUserChanged func(userID string) // auth cache eviction callback } func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache, objStore storage.ObjectStore) *AdminHandler { return &AdminHandler{stores: s, vault: vault, uekCache: uekCache, objStore: objStore} } // OnUserChanged registers a callback invoked when a user's role or // active status changes. Used to evict the auth middleware cache. func (h *AdminHandler) OnUserChanged(fn func(userID string)) { h.onUserChanged = fn } // ── 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{"data": 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"` IsAdmin bool `json:"is_admin"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) user := &models.User{ Username: strings.ToLower(req.Username), Email: strings.ToLower(req.Email), PasswordHash: string(hash), IsActive: true, Handle: auth.UniqueHandle(ctx, h.stores.Users, models.HandleFromName(req.Username)), } if err := h.stores.Users.Create(ctx, user); err != nil { if database.IsUniqueViolation(err) { c.JSON(http.StatusConflict, gin.H{"error": "username or email already exists"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) return } auth.EnsureEveryoneGroup(ctx, h.stores, user.ID) if req.IsAdmin { auth.AddToAdminsGroup(ctx, h.stores, user.ID) } 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") ctx := c.Request.Context() var req struct { IsAdmin bool `json:"is_admin"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if _, err := h.stores.Users.GetByID(ctx, id); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } if !req.IsAdmin { // Guard: prevent removing the last admin from Admins group. members, _ := h.stores.Groups.ListMembers(ctx, auth.AdminsGroupID) isMember := false for _, m := range members { if m.UserID == id { isMember = true break } } if isMember && len(members) <= 1 { c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last admin"}) return } auth.RemoveFromAdminsGroup(ctx, h.stores, id) } else { auth.AddToAdminsGroup(ctx, h.stores, id) } h.auditLog(c, "user.role_change", "user", id, gin.H{"is_admin": req.IsAdmin}) if h.onUserChanged != nil { h.onUserChanged(id) } c.JSON(http.StatusOK, gin.H{"message": "admin status 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}) if h.onUserChanged != nil { h.onUserChanged(id) } 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(), h.stores, 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(), h.stores, 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") // Guard: prevent deleting the last admin if _, err := h.stores.Users.GetByID(c.Request.Context(), id); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } members, _ := h.stores.Groups.ListMembers(c.Request.Context(), auth.AdminsGroupID) isAdmin := false for _, m := range members { if m.UserID == id { isAdmin = true break } } if isAdmin && len(members) <= 1 { c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"}) return } // Destroy vault and personal BYOK keys before deleting the user row h.destroyVault(c, 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") // If the key is a known policy, read from the policies table first if _, ok := models.PolicyDefaults[key]; ok { val, err := h.stores.Policies.Get(c.Request.Context(), key) if err == nil { c.JSON(http.StatusOK, gin.H{"key": key, "value": val}) return } // Fall through to global_config if not found in policies } 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); 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) c.JSON(http.StatusOK, gin.H{"message": "setting updated"}) } 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") policies, _ := h.stores.Policies.GetAll(c.Request.Context()) c.JSON(http.StatusOK, gin.H{ "banner": banner, "branding": branding, "storage_configured": storageConfigured, "policies": gin.H{ "allow_registration": policies["allow_registration"], }, }) } // ── 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) } // ── Stats ─────────────────────────────────── func (h *AdminHandler) GetStats(c *gin.Context) { ctx := c.Request.Context() stats := gin.H{} userCount, _ := h.stores.Users.CountAll(ctx) stats["users"] = userCount 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{}) { // Convert interface{} metadata to map[string]interface{} for the shared helper var meta map[string]interface{} if metadata != nil { b, _ := json.Marshal(metadata) json.Unmarshal(b, &meta) } AuditLog(h.stores.Audit, c, action, resourceType, resourceID, meta) }