Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
449 lines
14 KiB
Go
449 lines
14 KiB
Go
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")
|
|
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
|
|
}
|
|
|
|
// Retention TTL (v0.37.14 — days before purge for global/team channels)
|
|
retentionTTL := 0
|
|
if ttlCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "retention_ttl_days"); err == nil {
|
|
if v, ok := ttlCfg["value"].(float64); ok {
|
|
retentionTTL = int(v)
|
|
}
|
|
}
|
|
|
|
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"],
|
|
"retention_ttl_days": retentionTTL,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── 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)
|
|
}
|