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:
28
controllers/admin_registry.go
Normal file
28
controllers/admin_registry.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// AdminControllers is the plugin's admin controller list. app returns the
|
||||
// booted application; it is asked on every call, so a controller never keeps
|
||||
// a database handle, a config or a mailer of its own.
|
||||
func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
return []pact.AdminController{
|
||||
usersAdminController{app: app},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
var _ pact.AdminController = usersAdminController{}
|
||||
var _ pact.AdminPermissioned = usersAdminController{}
|
||||
var _ pact.AdminRecordSource = usersAdminController{}
|
||||
var _ pact.ListExtendQuery = usersAdminController{}
|
||||
var _ pact.FormExtendQuery = usersAdminController{}
|
||||
var _ pact.ListRowStates = usersAdminController{}
|
||||
var _ pact.FormRules = usersAdminController{}
|
||||
var _ pact.FilterOptions = usersAdminController{}
|
||||
var _ pact.FormBeforeUpdate = usersAdminController{}
|
||||
var _ pact.FormBeforeDelete = usersAdminController{}
|
||||
}
|
||||
23
controllers/request_db.go
Normal file
23
controllers/request_db.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// requestDB is the database handle a hook or scope should read through. Inside
|
||||
// an admin write it is the transaction the write runs in, so the hook sees the
|
||||
// write's snapshot and does not take a second pool connection while the
|
||||
// transaction holds row locks. Outside a transaction (the list) it is the
|
||||
// application pool from the booted app. It is nil when neither exists.
|
||||
func requestDB(ctx context.Context, pool func() *gorm.DB) *gorm.DB {
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||
return tx
|
||||
}
|
||||
if pool == nil {
|
||||
return nil
|
||||
}
|
||||
return pool()
|
||||
}
|
||||
14
controllers/users/config_filter.yaml
Normal file
14
controllers/users/config_filter.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
scopes:
|
||||
groups:
|
||||
label: golem15.user::lang.group.label
|
||||
modelClass: Golem15\User\Models\UserGroup
|
||||
nameFrom: name
|
||||
scope: filterByGroup
|
||||
created_date:
|
||||
label: golem15.user::lang.user.created_at
|
||||
type: daterange
|
||||
column: created_at
|
||||
activated:
|
||||
label: golem15.user::lang.user.status_activated
|
||||
type: switch
|
||||
column: is_activated
|
||||
14
controllers/users/config_form.yaml
Normal file
14
controllers/users/config_form.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
name: golem15.user::lang.user.label
|
||||
form: ~/plugins/golem15/user/models/user/fields.yaml
|
||||
modelClass: Golem15\User\Models\User
|
||||
defaultRedirect: golem15/user/users
|
||||
|
||||
update:
|
||||
redirect: golem15/user/users/update/:id
|
||||
redirectClose: golem15/user/users
|
||||
|
||||
messages:
|
||||
update: backend::lang.messages.form.update
|
||||
saved: backend::lang.messages.form.saved
|
||||
deleteConfirm: golem15.user::lang.users.delete_confirm
|
||||
deleted: backend::lang.messages.form.deleted
|
||||
20
controllers/users/config_list.yaml
Normal file
20
controllers/users/config_list.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
list: ~/plugins/golem15/user/models/user/columns.yaml
|
||||
modelClass: Golem15\User\Models\User
|
||||
title: golem15.user::lang.users.list_title
|
||||
recordUrl: golem15/user/users/update/:id
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
showSetup: true
|
||||
filter: config_filter.yaml
|
||||
|
||||
toolbar:
|
||||
buttons: []
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
messages:
|
||||
empty: golem15.user::lang.users.list_empty
|
||||
rowStateDeleted: golem15.user::lang.users.state_deactivated
|
||||
rowStateNegative: golem15.user::lang.users.state_banned
|
||||
rowStateDisabled: golem15.user::lang.users.state_not_activated
|
||||
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