mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-06-04 13:30:32 +08:00
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:
@@ -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_only(applyAuthAndTenantDefaults),
|
||||
// 作为 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 三级 resolver(DB > 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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user