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:
239
controllers/users_admin_controller.go
Normal file
239
controllers/users_admin_controller.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user