package api import ( "encoding/json" "fmt" "net/http" "strings" "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) type userPublic struct { ID string `json:"id"` Username string `json:"username"` Enabled bool `json:"enabled"` TOTPConfirmed bool `json:"totp_confirmed"` GroupNames []string `json:"group_names"` CreatedAt time.Time `json:"created_at"` PasswordChangedAt time.Time `json:"password_changed_at"` IsAdmin bool `json:"is_admin"` CanDelete bool `json:"can_delete"` } type usersResponse struct { Users []userPublic `json:"users"` } type createUserRequest struct { Username string `json:"username"` Password string `json:"password"` GroupNames []string `json:"group_names"` } type patchUserRequest struct { GroupNames *[]string `json:"group_names"` Password *string `json:"password"` DisableTOTP *bool `json:"disable_totp"` } type deleteUserRequest struct { CurrentPassword string `json:"current_password"` TOTPCode string `json:"totp_code"` } func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Request) { if err := app.requireKey(); err != nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) return } store, err := settings.LoadPasswords(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) } func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Request) { if err := app.authorizeUsersWrite(request); err != nil { writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) return } if err := app.requireKey(); err != nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) return } var payload createUserRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } username := strings.TrimSpace(payload.Username) if username == "" { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "username is required"}) return } if err := auth.ValidatePassword(payload.Password, username); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } groupNames, err := validateGroupNames(payload.GroupNames, settingsPayload.Groups) if err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } store, err := settings.LoadPasswords(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } for _, existing := range store.Users { if strings.EqualFold(existing.Username, username) { writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "username already exists"}) return } } hash, err := auth.HashPassword(payload.Password) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } userID, err := newUserID() if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } now := time.Now().UTC() store.Users = append(store.Users, settings.UserCredential{ ID: userID, Username: username, PasswordHash: hash, Enabled: true, GroupNames: groupNames, CreatedAt: now, PasswordChangedAt: now, }) if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) } func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Request) { if err := app.authorizeUsersWrite(request); err != nil { writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) return } if err := app.requireKey(); err != nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) return } if err := app.requireGraceReauth(request); err != nil { if err.Error() == reauthRequiredMessage { writeReauthRequired(writer) return } writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()}) return } userID := strings.TrimSpace(request.URL.Query().Get("id")) if userID == "" { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"}) return } var payload patchUserRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } if payload.GroupNames == nil && payload.Password == nil && payload.DisableTOTP == nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "no fields to update"}) return } settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } store, err := settings.LoadPasswords(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } index := -1 for i := range store.Users { if store.Users[i].ID == userID { index = i break } } if index < 0 { writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"}) return } target := &store.Users[index] passwordChanged := false if payload.GroupNames != nil { groupNames, err := validateGroupNames(*payload.GroupNames, settingsPayload.Groups) if err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } wasAdmin := userIsAdministrator(*target) wouldBeAdmin := false for _, name := range groupNames { if name == settings.AdministratorsGroupName { wouldBeAdmin = true break } } if wasAdmin && !wouldBeAdmin && countAdministrators(store.Users) <= 1 { writeJSON(writer, http.StatusConflict, apiErrorResponse{ Error: "cannot remove the last administrator from the Administrators group", }) return } target.GroupNames = groupNames } if payload.Password != nil { if err := auth.ValidatePassword(*payload.Password, target.Username); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } hash, err := auth.HashPassword(*payload.Password) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } target.PasswordHash = hash target.PasswordChangedAt = time.Now().UTC() passwordChanged = true } if payload.DisableTOTP != nil && *payload.DisableTOTP { target.TOTPSecret = "" target.TOTPConfirmed = false } if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } if passwordChanged && app.Sessions != nil { _ = app.Sessions.InvalidateUserSessions(userID) } writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) } func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Request) { if err := app.authorizeUsersWrite(request); err != nil { writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) return } if err := app.requireKey(); err != nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) return } userID := strings.TrimSpace(request.URL.Query().Get("id")) if userID == "" { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"}) return } var payload deleteUserRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()}) return } store, err := settings.LoadPasswords(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } adminCount := countAdministrators(store.Users) remaining := make([]settings.UserCredential, 0, len(store.Users)) found := false for _, user := range store.Users { if user.ID != userID { remaining = append(remaining, user) continue } found = true if userIsAdministrator(user) && adminCount <= 1 { writeJSON(writer, http.StatusConflict, apiErrorResponse{ Error: "cannot delete the last administrator", }) return } } if !found { writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"}) return } store.Users = remaining if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } if app.Sessions != nil { _ = app.Sessions.InvalidateUserSessions(userID) } writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) } func validateGroupNames(names []string, groups []settings.Group) ([]string, error) { if names == nil { return []string{}, nil } known := make(map[string]struct{}, len(groups)) for _, group := range groups { known[group.Name] = struct{}{} } result := make([]string, 0, len(names)) seen := make(map[string]struct{}, len(names)) for _, name := range names { trimmed := strings.TrimSpace(name) if trimmed == "" { continue } if _, ok := known[trimmed]; !ok { return nil, fmt.Errorf("unknown group: %s", trimmed) } if _, ok := seen[trimmed]; ok { continue } seen[trimmed] = struct{}{} result = append(result, trimmed) } return result, nil } func toPublicUsers(users []settings.UserCredential) []userPublic { adminCount := countAdministrators(users) result := make([]userPublic, 0, len(users)) for _, user := range users { isAdmin := userIsAdministrator(user) groupNames := user.GroupNames if groupNames == nil { groupNames = []string{} } result = append(result, userPublic{ ID: user.ID, Username: user.Username, Enabled: user.Enabled, TOTPConfirmed: user.TOTPConfirmed, GroupNames: groupNames, CreatedAt: user.CreatedAt, PasswordChangedAt: user.PasswordChangedAt, IsAdmin: isAdmin, CanDelete: !(isAdmin && adminCount <= 1), }) } return result } func userIsAdministrator(user settings.UserCredential) bool { for _, groupName := range user.GroupNames { if groupName == settings.AdministratorsGroupName { return true } } return false } func countAdministrators(users []settings.UserCredential) int { count := 0 for _, user := range users { if userIsAdministrator(user) { count++ } } return count }