feat(system-admin): implement bootstrap for system admin promotion and enhance system settings management

- Added WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL environment variable to promote a specified user to system admin on startup.
- Introduced a new bootstrap process in `bootstrap.go` to handle the promotion logic.
- Updated `.env.example` to document the new environment variable and its behavior.
- Created new views for managing system administrators and system settings, including listing, promoting, and revoking admin privileges.
- Enhanced the frontend to reflect the new system admin features, including UI elements for admin management and settings configuration.
- Updated API interfaces to support system admin functionalities, ensuring proper data handling and user management.
This commit is contained in:
wizardchen
2026-05-24 21:38:05 +08:00
committed by lyingbug
parent e6ee87759d
commit 47a183aa65
43 changed files with 3322 additions and 146 deletions

View File

@@ -23,19 +23,28 @@ import (
// Provides functionality for user registration, login, logout, and token management
// through the REST API endpoints
type AuthHandler struct {
userService interfaces.UserService
tenantService interfaces.TenantService
configInfo *config.Config
userService interfaces.UserService
tenantService interfaces.TenantService
configInfo *config.Config
systemSettingSvc interfaces.SystemSettingService
}
// NewAuthHandler creates a new auth handler instance with the provided services
// Parameters:
// - userService: An implementation of the UserService interface for business logic
// - tenantService: An implementation of the TenantService interface for tenant management
// - systemSettingSvc: 3-tier resolver for runtime-tunable settings such as
// auth.registration_mode (P3). When DB has a row, it overrides cfg's
// startup value; otherwise we fall back to cfg.Auth.RegistrationMode
// (which already accounted for the legacy DISABLE_REGISTRATION env coerce
// during config load). Mismatch impossible by construction since the
// handler always passes cfg's value as the def parameter to GetString.
//
// Returns a pointer to the newly created AuthHandler
func NewAuthHandler(configInfo *config.Config,
userService interfaces.UserService, tenantService interfaces.TenantService) *AuthHandler {
userService interfaces.UserService, tenantService interfaces.TenantService,
systemSettingSvc interfaces.SystemSettingService,
) *AuthHandler {
// Boot-time guard: a nil-or-empty Auth section silently disables the
// invite_only gate (see Register below). Emit a loud one-shot log
// pointing at the misconfiguration so operators notice on startup
@@ -47,12 +56,40 @@ func NewAuthHandler(configInfo *config.Config,
configInfo)
}
return &AuthHandler{
configInfo: configInfo,
userService: userService,
tenantService: tenantService,
configInfo: configInfo,
userService: userService,
tenantService: tenantService,
systemSettingSvc: systemSettingSvc,
}
}
// resolveRegistrationMode returns the currently active registration mode.
// Priority: DB system_settings > cfg (which already absorbed the legacy
// DISABLE_REGISTRATION env coerce at startup) > "self_serve" hard default.
//
// Centralised here so /auth/register and /auth/config stay in lock-step —
// otherwise a SystemAdmin's UI edit could affect one path and not the other.
func (h *AuthHandler) resolveRegistrationMode(ctx context.Context) string {
// cfg-derived default: empty is impossible after applyAuthAndTenantDefaults,
// but be defensive in case AuthHandler was constructed before that ran
// (the NewAuthHandler guard already logged in that case).
def := config.AuthRegistrationModeSelfServe
if h.configInfo != nil && h.configInfo.Auth != nil {
if m := strings.TrimSpace(h.configInfo.Auth.RegistrationMode); m != "" {
def = m
}
}
if h.systemSettingSvc == nil {
return def
}
// envName = "" because DISABLE_REGISTRATION is a boolean and
// auth.registration_mode is a string — the legacy env was already
// coerced into `def` above. Mixing the two semantics at the resolver
// layer would mean a UI delete (DB row absent) silently flipped to
// the legacy boolean read again, which is surprising.
return h.systemSettingSvc.GetString(ctx, "auth.registration_mode", "", def)
}
// Register godoc
// @Summary 用户注册
// @Description 注册新用户账号
@@ -70,11 +107,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
logger.Info(ctx, "Start user registration")
// 当 auth.registration_mode=invite_only 时public 注册被关闭。
// 新成员只能由 Owner 通过 /tenants/:id/members 添加PR 3 of #1303
// 前端在 PR 1 已经会读 /auth/config 隐藏注册入口;这里是直接 API 调用的兜底。
// 历史变量 DISABLE_REGISTRATION=true 在 config 启动阶段已被等价提升为
// invite_only因此这里只剩一条 gate。
if h.configInfo != nil && h.configInfo.Auth != nil && h.configInfo.Auth.IsInviteOnly() {
// 优先级DB system_settings > cfg.Auth.RegistrationMode > "self_serve"
// SystemAdmin 通过「全局设置」UI 实时切换 self_serve / invite_only立即
// 生效,不需要重启服务。历史变量 DISABLE_REGISTRATION=true 在 config
// 启动阶段被等价提升为 invite_onlyapplyAuthAndTenantDefaults
// 作为 cfg-default 进入 resolveRegistrationMode。
if h.resolveRegistrationMode(ctx) == config.AuthRegistrationModeInviteOnly {
logger.Warn(ctx, "Registration rejected: auth.registration_mode=invite_only")
appErr := errors.NewForbiddenError("Registration is invite-only")
c.Error(appErr)
@@ -612,12 +650,9 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
// only what the UI strictly needs (registration_mode); other config
// stays internal.
func (h *AuthHandler) GetAuthConfig(c *gin.Context) {
mode := config.AuthRegistrationModeSelfServe
if h.configInfo != nil && h.configInfo.Auth != nil {
if m := strings.TrimSpace(h.configInfo.Auth.RegistrationMode); m != "" {
mode = m
}
}
// Same source-of-truth as Register's gate, so the UI hide-the-button
// signal can never disagree with the API enforcement signal.
mode := h.resolveRegistrationMode(c.Request.Context())
c.JSON(http.StatusOK, gin.H{
"success": true,
"registration_mode": mode,

View File

@@ -89,7 +89,7 @@ func TestRegister_InviteOnlyRejects(t *testing.T) {
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeInviteOnly},
}, us, nil)
}, us, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusForbidden {
@@ -114,7 +114,7 @@ func TestRegister_SelfServeAllowsRegistration(t *testing.T) {
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeSelfServe},
}, us, nil)
}, us, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {
@@ -135,7 +135,7 @@ func TestRegister_NilAuthConfigDoesNotPanic(t *testing.T) {
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{}, us, nil)
h := NewAuthHandler(&config.Config{}, us, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {

View File

@@ -61,6 +61,7 @@ type InitializationHandler struct {
ollamaService *ollama.OllamaService
documentReader interfaces.DocumentReader
pooler embedding.EmbedderPooler
systemSettingSvc interfaces.SystemSettingService
}
// NewInitializationHandler 创建初始化处理器
@@ -74,6 +75,7 @@ func NewInitializationHandler(
ollamaService *ollama.OllamaService,
documentReader interfaces.DocumentReader,
pooler embedding.EmbedderPooler,
systemSettingSvc interfaces.SystemSettingService,
) *InitializationHandler {
return &InitializationHandler{
config: config,
@@ -85,6 +87,7 @@ func NewInitializationHandler(
ollamaService: ollamaService,
documentReader: documentReader,
pooler: pooler,
systemSettingSvc: systemSettingSvc,
}
}
@@ -2120,11 +2123,12 @@ func (h *InitializationHandler) TestMultimodalFunction(c *gin.Context) {
return
}
// 验证文件大小 (default 50MB, configurable via MAX_FILE_SIZE_MB)
maxSize := utils.GetMaxFileSize()
// 验证文件大小 — 走 system_settings 三级 resolverDB > ENV > 50 默认)
maxSizeMB := h.systemSettingSvc.GetInt(ctx, "file.max_size_mb", "MAX_FILE_SIZE_MB", 50)
maxSize := maxSizeMB * 1024 * 1024
if header.Size > maxSize {
logger.Error(ctx, "File size too large")
c.Error(errors.NewBadRequestError(fmt.Sprintf("图片文件大小不能超过%dMB", utils.GetMaxFileSizeMB())))
c.Error(errors.NewBadRequestError(fmt.Sprintf("图片文件大小不能超过%dMB", maxSizeMB)))
return
}
logger.Infof(ctx, "Processing image: %s", utils.SanitizeForLog(header.Filename))

View File

@@ -34,6 +34,9 @@ type KnowledgeHandler struct {
kbShareService interfaces.KBShareService
agentShareService interfaces.AgentShareService
asynqClient interfaces.TaskEnqueuer
// systemSettingSvc consults the platform-wide system_settings table
// for runtime tunables (file size limit etc.), with ENV/default fallback.
systemSettingSvc interfaces.SystemSettingService
}
// NewKnowledgeHandler creates a new knowledge handler instance
@@ -43,6 +46,7 @@ func NewKnowledgeHandler(
kbShareService interfaces.KBShareService,
agentShareService interfaces.AgentShareService,
asynqClient interfaces.TaskEnqueuer,
systemSettingSvc interfaces.SystemSettingService,
) *KnowledgeHandler {
return &KnowledgeHandler{
kgService: kgService,
@@ -50,6 +54,7 @@ func NewKnowledgeHandler(
kbShareService: kbShareService,
agentShareService: agentShareService,
asynqClient: asynqClient,
systemSettingSvc: systemSettingSvc,
}
}
@@ -266,11 +271,14 @@ func (h *KnowledgeHandler) CreateKnowledgeFromFile(c *gin.Context) {
return
}
// Validate file size (configurable via MAX_FILE_SIZE_MB)
maxSize := secutils.GetMaxFileSize()
// Validate file size — 3-tier resolver (DB > ENV > 50 MB default).
// SystemAdmin can update this via the global-settings UI and the
// new value applies on the very next upload (no restart needed).
maxSizeMB := h.systemSettingSvc.GetInt(ctx, "file.max_size_mb", "MAX_FILE_SIZE_MB", 50)
maxSize := maxSizeMB * 1024 * 1024
if file.Size > maxSize {
logger.Error(ctx, "File size too large")
c.Error(errors.NewBadRequestError(fmt.Sprintf("文件大小不能超过%dMB", secutils.GetMaxFileSizeMB())))
c.Error(errors.NewBadRequestError(fmt.Sprintf("文件大小不能超过%dMB", maxSizeMB)))
return
}

View File

@@ -28,6 +28,7 @@ type Handler struct {
fileService interfaces.FileService // Service for file storage (image uploads)
modelService interfaces.ModelService // Service for model management (VLM access)
userService interfaces.UserService // Service for resolving per-user preferences (e.g. enable_memory default)
systemSettingSvc interfaces.SystemSettingService // 3-tier resolver for runtime tunables (file size limit etc.)
attachmentProcessor *AttachmentProcessor // Processor for file attachments
}
@@ -47,6 +48,7 @@ func NewHandler(
userService interfaces.UserService,
documentReader interfaces.DocumentReader,
imageResolver *docparser.ImageResolver,
systemSettingSvc interfaces.SystemSettingService,
) *Handler {
return &Handler{
sessionService: sessionService,
@@ -61,6 +63,7 @@ func NewHandler(
fileService: fileService,
modelService: modelService,
userService: userService,
systemSettingSvc: systemSettingSvc,
attachmentProcessor: NewAttachmentProcessor(
fileService,
documentReader,

View File

@@ -170,11 +170,14 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
if len(request.AttachmentUploads) > 0 {
logger.Infof(ctx, "[%s] processing %d attachment(s)", logPrefix, len(request.AttachmentUploads))
maxSize := secutils.GetMaxFileSize()
// 3-tier resolver: DB > ENV > 50MB. Edits via the system-admin
// settings UI take effect on the very next request.
maxSizeMB := h.systemSettingSvc.GetInt(ctx, "file.max_size_mb", "MAX_FILE_SIZE_MB", 50)
maxSize := maxSizeMB * 1024 * 1024
for i, upload := range request.AttachmentUploads {
if upload.FileSize > maxSize {
return nil, nil, errors.NewBadRequestError(
fmt.Sprintf("attachment %d exceeds size limit of %dMB", i+1, secutils.GetMaxFileSizeMB()))
fmt.Sprintf("attachment %d exceeds size limit of %dMB", i+1, maxSizeMB))
}
}

View File

@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/application/service"
@@ -25,10 +27,12 @@ import (
// SystemHandler handles system-related requests
type SystemHandler struct {
cfg *config.Config
neo4jDriver neo4j.Driver
documentReader interfaces.DocumentReader
tenantSvc interfaces.TenantService
cfg *config.Config
neo4jDriver neo4j.Driver
documentReader interfaces.DocumentReader
tenantSvc interfaces.TenantService
userSvc interfaces.UserService
systemSettingSvc interfaces.SystemSettingService
}
// NewSystemHandler creates a new system handler
@@ -36,12 +40,16 @@ func NewSystemHandler(cfg *config.Config,
neo4jDriver neo4j.Driver,
documentReader interfaces.DocumentReader,
tenantSvc interfaces.TenantService,
userSvc interfaces.UserService,
systemSettingSvc interfaces.SystemSettingService,
) *SystemHandler {
return &SystemHandler{
cfg: cfg,
neo4jDriver: neo4jDriver,
documentReader: documentReader,
tenantSvc: tenantSvc,
cfg: cfg,
neo4jDriver: neo4jDriver,
documentReader: documentReader,
tenantSvc: tenantSvc,
userSvc: userSvc,
systemSettingSvc: systemSettingSvc,
}
}
@@ -1006,3 +1014,324 @@ func (h *SystemHandler) ResolveDocumentReader(ctx context.Context, addr string)
}
return reader
}
// PromoteUserToSystemAdminRequest defines the request for promoting a user to system admin
type PromoteUserToSystemAdminRequest struct {
UserID string `json:"user_id" binding:"required"`
}
// PromoteUserToSystemAdmin godoc
// @Summary Promote a user to system administrator
// @Description Grant system administrator privileges to a user (SystemAdmin only).
// @Description Idempotent: re-promoting an existing system admin returns 200 with no DB write.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param request body PromoteUserToSystemAdminRequest true "User promotion request"
// @Success 200 {object} types.UserInfo "User promoted successfully"
// @Failure 400 {object} map[string]interface{} "Bad request"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Failure 404 {object} map[string]interface{} "User not found"
// @Router /system/admin/promote [post]
func (h *SystemHandler) PromoteUserToSystemAdmin(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req PromoteUserToSystemAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
user, err := h.userSvc.GetUserByID(ctx, req.UserID)
if err != nil {
logger.Errorf(ctx, "Error fetching user %s: %v", req.UserID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if user.IsSystemAdmin {
// Idempotent: re-promoting an existing system admin is a no-op success.
c.JSON(http.StatusOK, user.ToUserInfo())
return
}
user.IsSystemAdmin = true
if err := h.userSvc.UpdateUser(ctx, user); err != nil {
logger.Errorf(ctx, "Error promoting user %s to system admin: %v", req.UserID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to promote user"})
return
}
logger.Infof(ctx, "User %s (ID: %s) promoted to system admin", user.Username, user.ID)
c.JSON(http.StatusOK, user.ToUserInfo())
}
// RevokeSystemAdminRequest defines the request for revoking system admin privileges
type RevokeSystemAdminRequest struct {
UserID string `json:"user_id" binding:"required"`
}
// RevokeSystemAdmin godoc
// @Summary Revoke system administrator privileges from a user
// @Description Remove system administrator privileges from a user (SystemAdmin only).
// @Description Two safety guards: the caller cannot revoke their own privileges,
// @Description and revoking the last remaining system admin is rejected — both
// @Description prevent a SystemAdmin from accidentally locking the platform out
// @Description of system-level administration. Idempotent on already-non-admin users.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param request body RevokeSystemAdminRequest true "User revocation request"
// @Success 200 {object} types.UserInfo "Privileges revoked successfully"
// @Failure 400 {object} map[string]interface{} "Bad request / would remove last admin / self-revoke"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Failure 404 {object} map[string]interface{} "User not found"
// @Router /system/admin/revoke [post]
func (h *SystemHandler) RevokeSystemAdmin(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req RevokeSystemAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Self-revoke guard. Without it, a single careless click could leave a
// deployment with zero system admins and no UI path to recover —
// operators would have to set the env-var bootstrap or hand-edit the DB.
if callerID, _ := types.UserIDFromContext(ctx); callerID == req.UserID {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cannot revoke your own system admin privileges",
})
return
}
user, err := h.userSvc.GetUserByID(ctx, req.UserID)
if err != nil {
logger.Errorf(ctx, "Error fetching user %s: %v", req.UserID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if !user.IsSystemAdmin {
// Idempotent: revoking from a non-admin is a no-op success.
c.JSON(http.StatusOK, user.ToUserInfo())
return
}
// Last-admin guard. ListSystemAdmins is bounded to a single row here
// because we only need the total count; this stays O(1) on the
// is_system_admin index. Combined with the self-revoke guard above,
// these two checks make it impossible for a SystemAdmin to lock
// themselves out of system-level administration via this endpoint.
_, total, err := h.userSvc.ListSystemAdmins(ctx, 0, 1)
if err != nil {
logger.Errorf(ctx, "Error counting system admins for last-admin check: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify admin count"})
return
}
if total <= 1 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cannot revoke the last remaining system administrator",
})
return
}
user.IsSystemAdmin = false
if err := h.userSvc.UpdateUser(ctx, user); err != nil {
logger.Errorf(ctx, "Error revoking system admin from user %s: %v", req.UserID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke system admin privileges"})
return
}
logger.Infof(ctx, "System admin privileges revoked from user %s (ID: %s)", user.Username, user.ID)
c.JSON(http.StatusOK, user.ToUserInfo())
}
// ListSystemAdminsResponse defines the response structure for listing system admins.
// Total reflects the underlying COUNT(*), not just the page size, so the front
// end can render pagination metadata without a follow-up call.
type ListSystemAdminsResponse struct {
Total int64 `json:"total"`
Admins []*types.UserInfo `json:"admins"`
}
// ListSystemAdmins godoc
// @Summary List all system administrators
// @Description Retrieve a paginated list of users with system administrator
// @Description privileges (SystemAdmin only). Supports `offset` (default 0)
// @Description and `limit` (default 50, max 200) query parameters. Walks the
// @Description partial-friendly idx_users_is_system_admin index.
// @Tags System Admin
// @Produce json
// @Param offset query int false "Page offset" default(0)
// @Param limit query int false "Page size (max 200)" default(50)
// @Success 200 {object} ListSystemAdminsResponse "System admins retrieved successfully"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Router /system/admin/list [get]
func (h *SystemHandler) ListSystemAdmins(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
// Best-effort pagination parsing — a malformed `limit=foo` falls back
// to defaults rather than 400-ing, since the call is still safe and a
// failed-page is more user-hostile than a soft default.
offset := 0
limit := 50
if v := c.Query("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
// Cap so a client can't ask for the entire table.
if limit > 200 {
limit = 200
}
users, total, err := h.userSvc.ListSystemAdmins(ctx, offset, limit)
if err != nil {
logger.Errorf(ctx, "Error listing system admins: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list system admins"})
return
}
// Always emit a non-nil slice so the JSON serialises to `[]` rather
// than `null` for an empty page — front-end iteration is safer.
infos := make([]*types.UserInfo, 0, len(users))
for _, u := range users {
infos = append(infos, u.ToUserInfo())
}
c.JSON(http.StatusOK, ListSystemAdminsResponse{
Total: total,
Admins: infos,
})
}
// ============================================================================
// System Settings (P1)
// ----------------------------------------------------------------------------
// Endpoints below are mounted under /api/v1/system/admin/settings*, all
// gated to SystemAdmin via the route group's middleware. Every response
// is the raw model — no `gin.H{"data": ...}` wrapping — to match the
// project's axios interceptor contract (response.data is unwrapped at the
// HTTP layer; see frontend/src/utils/request.ts:97). The P0 ListSystemAdmins
// already follows this; do not break the convention.
// ============================================================================
// ListSystemSettings godoc
// @Summary List all system settings
// @Description Return every row in the system_settings table (system-scope,
// @Description not tenant-scope). SystemAdmin only.
// @Tags System Admin
// @Produce json
// @Success 200 {array} types.SystemSetting "list of settings"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Router /system/admin/settings [get]
func (h *SystemHandler) ListSystemSettings(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
rows, err := h.systemSettingSvc.List(ctx)
if err != nil {
logger.Errorf(ctx, "list system settings failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list system settings"})
return
}
if rows == nil {
// Always emit a non-nil array so the JSON serialises to `[]`
// rather than `null` on an empty table — front-end iteration
// is safer.
rows = []*types.SystemSetting{}
}
c.JSON(http.StatusOK, rows)
}
// GetSystemSetting godoc
// @Summary Get a single system setting by key
// @Description Returns the row matching :key. 404 when the key is unknown
// @Description to the registry; 200 with the row when known.
// @Tags System Admin
// @Produce json
// @Param key path string true "Setting key (e.g. file.max_size_mb)"
// @Success 200 {object} types.SystemSetting "the setting row"
// @Failure 400 {object} map[string]interface{} "Unknown key"
// @Failure 404 {object} map[string]interface{} "Key registered but DB row absent"
// @Router /system/admin/settings/{key} [get]
func (h *SystemHandler) GetSystemSetting(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
key := c.Param("key")
row, err := h.systemSettingSvc.Get(ctx, key)
if err != nil {
// Service-layer "unknown key" surfaces as a generic error here;
// distinguish via the error string rather than typed errors so
// we don't grow a sentinel package for a single error class.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if row == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "setting not yet persisted"})
return
}
c.JSON(http.StatusOK, row)
}
// UpdateSystemSettingRequest is the body for PUT /system/admin/settings/:key.
// `value` carries the new value as raw JSON — int / string / bool depending
// on the registry-declared value_type. The service validates the type
// strictly and rejects mismatches with 400.
type UpdateSystemSettingRequest struct {
// Value is intentionally `any` (decoded as float64 / string / bool /
// etc. by the JSON unmarshaller). Service.encodeForType normalises
// these against the registry's declared type and rejects mismatches.
Value any `json:"value"`
}
// UpdateSystemSetting godoc
// @Summary Update a system setting value
// @Description Persist a new value for :key. Service validates the
// @Description rawValue against the registry's declared value_type and
// @Description rejects mismatches with 400. SystemAdmin only. Emits an
// @Description audit row (action=system.setting_changed) on success.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param key path string true "Setting key"
// @Param request body UpdateSystemSettingRequest true "New value"
// @Success 200 {object} types.SystemSetting "the updated row"
// @Failure 400 {object} map[string]interface{} "Bad request / unknown key / type mismatch"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Router /system/admin/settings/{key} [put]
func (h *SystemHandler) UpdateSystemSetting(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
key := c.Param("key")
var req UpdateSystemSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
if req.Value == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "value is required"})
return
}
row, err := h.systemSettingSvc.Update(ctx, key, req.Value)
if err != nil {
// Whether this is "unknown key" / "type mismatch" / "DB error"
// is encoded in the error message at the service layer; surface
// it verbatim. UI captures it as the toast text.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, row)
}