feat: register the Users admin screen with the privileged-member guard

- permissions (the four Winter codes and manage_privileged_groups),
  the user navigation item and the embedded admin YAML
- Users controller: list with deactivated users, row states, the
  groups, created date and activated filters, and the form's name,
  surname and email
- a member of a group in golem15.user.privileged_groups (default
  admin) keeps their email, password and row unless the administrator
  holds golem15.users.manage_privileged_groups; the refusal is a 403
  that saves nothing
This commit is contained in:
Jakub Zych
2026-10-05 13:01:18 +02:00
parent e22f7660f3
commit b410a7fea9
17 changed files with 739 additions and 2 deletions

View File

@@ -0,0 +1,239 @@
package controllers
import (
"context"
"errors"
"strconv"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// PermissionAccessUsers is the backend permission of the Users screen, the
// PHP Users::$requiredPermissions.
const PermissionAccessUsers = "golem15.users.access_users"
// usersLang prefixes the phrase keys of the Users screen.
const usersLang = "golem15.user::lang.users."
// usersAdminController is the Winter Users backend controller: the list with
// its filters and row states, and the form. The screen and every write need
// golem15.users.access_users.
//
// app is resolved per call from the booted plugin. Nothing is cached: the
// database handle, the config and the privileged group list are read when a
// request needs them.
type usersAdminController struct {
app func() *backpack.App
}
func (usersAdminController) ID() string { return "golem15.user.users" }
func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` }
func (usersAdminController) ConfigDir() string { return "controllers/users" }
func (usersAdminController) RequiredPermissions() []string {
return []string{PermissionAccessUsers}
}
func (usersAdminController) NewRecord() any { return &models.User{} }
// application returns the booted app, or nil before boot.
func (c usersAdminController) application() *backpack.App {
if c.app == nil {
return nil
}
return c.app()
}
// db is the application pool, or nil when the app has none.
func (c usersAdminController) db() *gorm.DB {
app := c.application()
if app == nil {
return nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok {
return nil
}
return gdb
}
// config is the application config, or nil before boot.
func (c usersAdminController) config() *compass.Config {
app := c.application()
if app == nil {
return nil
}
return app.Config
}
// ListExtendQuery and FormExtendQuery include deactivated (soft-deleted)
// users, the PHP listExtendQuery and formExtendQuery withTrashed().
func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
return db.Unscoped()
}
func (usersAdminController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
return db.Unscoped()
}
// ListRowStates is the PHP listInjectRowClass: deleted for a deactivated
// user, negative for a banned one, disabled for one who is not activated.
// The ban state of the whole page comes from one user_throttle query.
func (c usersAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
ids := make([]uint, 0, len(records))
for _, record := range records {
user, ok := record.(*models.User)
if !ok || user == nil {
return nil, errors.New("users admin: list record is not a user")
}
ids = append(ids, user.ID)
}
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, c.application()), db, ids)
if err != nil {
return nil, err
}
out := make([][]pact.RowState, len(records))
for i, record := range records {
user := record.(*models.User)
if user.DeletedAt.Valid {
out[i] = append(out[i], pact.RowStateDeleted)
}
if banned[user.ID] {
out[i] = append(out[i], pact.RowStateNegative)
}
if !user.IsActivated {
out[i] = append(out[i], pact.RowStateDisabled)
}
}
return out, nil
}
// FormRules are the rules of an admin save. They replace User.Rules(), which
// is the register contract and would ask every admin update for a confirmed
// password.
func (usersAdminController) FormRules(_ context.Context, _ string) map[string]string {
return map[string]string{
"email": "required|between:6,255|email|unique:users",
}
}
// FilterOptions serves the choices of the groups filter (scope
// filterByGroup): every user group by name. Any other scope has no choices.
func (c usersAdminController) FilterOptions(scope string) []pact.Option {
if scope != models.FilterByGroup {
return nil
}
db := c.db()
if db == nil {
return nil
}
var groups []models.UserGroup
if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&groups).Error; err != nil {
return nil
}
out := make([]pact.Option, 0, len(groups))
for _, group := range groups {
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(group.ID), 10), Label: group.Name})
}
return out
}
// guardPrivilegedMember protects the account of a privileged-group member
// against takeover. It answers nil when the administrator on ctx holds
// golem15.users.manage_privileged_groups, or when the user is not a member of
// a group whose code is in golem15.user.privileged_groups; otherwise it
// refuses with a 403 carrying message and details. Membership is read
// through the write's transaction. A failed query is returned as it is.
func (c usersAdminController) guardPrivilegedMember(ctx context.Context, userID uint, message string, details map[string]any) error {
principal, _ := bouncer.User(ctx)
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {
return nil
}
db := requestDB(ctx, c.db)
if db == nil {
return errors.New("users admin: gorm handle is missing")
}
privileged, err := classes.IsPrivilegedMember(ctx, db, classes.PrivilegedGroupCodes(c.config()), userID)
if err != nil {
return err
}
if !privileged {
return nil
}
return &cabana.ForbiddenError{Message: message, Details: details}
}
// FormBeforeUpdate starts with the takeover guard: a changed email or a
// submitted password on a member of a privileged group needs
// golem15.users.manage_privileged_groups. Everything else an update does
// comes after it.
func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
user, ok := model.(*models.User)
if !ok || user == nil {
return errors.New("users admin: form model is not a user")
}
if err := c.guardCredentials(ctx, user); err != nil {
return err
}
return nil
}
// guardCredentials compares the email the save would write with the stored
// one and looks for a submitted password, then asks guardPrivilegedMember
// for the fields that would change.
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
db := requestDB(ctx, c.db)
if db == nil {
return errors.New("users admin: gorm handle is missing")
}
var stored []string
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
if err != nil {
return err
}
if len(stored) != 1 {
return errors.New("users admin: the user being updated was not found")
}
const message = usersLang + "privileged_member_forbidden"
details := map[string]any{}
if stored[0] != user.Email {
details["email"] = []string{message}
}
if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {
if password, _ := values["password"].(string); password != "" {
details["password"] = []string{message}
}
}
if len(details) == 0 {
return nil
}
return c.guardPrivilegedMember(ctx, user.ID, message, details)
}
// FormBeforeDelete refuses the delete of a privileged-group member without
// golem15.users.manage_privileged_groups. The framework calls it for the
// form's delete and once per record inside the bulk delete's transaction, so
// one refused record rolls the whole selection back.
func (c usersAdminController) FormBeforeDelete(ctx context.Context, model any) error {
user, ok := model.(*models.User)
if !ok || user == nil {
return errors.New("users admin: form model is not a user")
}
return c.guardPrivilegedMember(ctx, user.ID, usersLang+"privileged_member_delete_forbidden", nil)
}