Files
summercms/modules/cabana/backend_users_admin.go
Jakub Zych 88401737c9 feat(19.6.1-01): administrator form with server-side privilege rules
- create and edit administrators: password (min 4, max 72 bytes, bcrypt
  at admin.password.bcrypt_cost), role picker, radio permission overrides,
  superuser switch; no mail on create
- superusers hidden from non-superusers in list, lookups and schemas;
  is_superuser projected to superusers so an untouched save keeps it
- save-path rules: superuser-only switch, role and permission ceilings,
  no outranking, no own role/flag/overrides/password change, last active
  superuser not demoted (advisory lock + count in the write tx)
- password set for another admin bumps tokens_valid_after
- role, superuser, activation and last sign-in list filters
2026-10-22 11:08:12 +02:00

612 lines
20 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
// usersAdmin is the Administrators screen (summercms.cabana.users), the
// WinterCMS Backend\Controllers\Users controller over backend_users. Every
// route needs backend.manage_users. screens gives access to the activating
// app and the compiled registry at request time.
type usersAdmin struct {
screens *backendScreens
}
var (
_ pact.AdminController = usersAdmin{}
_ pact.AdminPermissioned = usersAdmin{}
_ pact.AdminRecordSource = usersAdmin{}
_ pact.FormRecordTitle = usersAdmin{}
_ FieldRelationProvider = usersAdmin{}
_ pact.ListExtendQuery = usersAdmin{}
_ pact.FormExtendQuery = usersAdmin{}
_ pact.FormVirtualFields = usersAdmin{}
_ pact.FormRules = usersAdmin{}
_ pact.FilterOptions = usersAdmin{}
_ pact.FormBeforeCreate = usersAdmin{}
_ pact.FormBeforeUpdate = usersAdmin{}
_ pact.FormAfterUpdate = usersAdmin{}
_ RelationLockProvider = usersAdmin{}
_ PermissionEditorProvider = usersAdmin{}
_ recordDataExtender = usersAdmin{}
_ schemaFieldHider = usersAdmin{}
)
func (usersAdmin) ID() string { return AdministratorsControllerID }
func (usersAdmin) ModelName() string { return BackendUserMorphType }
func (usersAdmin) ConfigDir() string { return "controllers/users" }
func (usersAdmin) RequiredPermissions() []string { return []string{PermissionManageUsers} }
func (usersAdmin) NewRecord() any { return &BackendUser{} }
// application is the activating app, or nil.
func (c usersAdmin) application() *backpack.App { return c.screens.application() }
// registry is the compiled registry, or nil before compilation finished.
func (c usersAdmin) registry() *Registry { return c.screens.registry() }
// FormRecordTitle names an administrator "First Last" when either name is
// set, otherwise by the login.
func (usersAdmin) FormRecordTitle(_ context.Context, _ *gorm.DB, record any) (string, error) {
user, ok := record.(*BackendUser)
if !ok || user == nil {
return "", errors.New("cabana: administrators: record is not a backend user")
}
if name := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName)); name != "" {
return name, nil
}
return user.Login, nil
}
// AdminFieldRelations binds the role picker. role_id is a protected column,
// so the contract opts in to writing it through this one field; the
// submitted id is still checked against the options and the role locks.
func (usersAdmin) AdminFieldRelations() []FieldRelationContract {
return []FieldRelationContract{{
Field: "role",
Kind: "belongsTo",
NewRelated: func() any { return &BackendUserRole{} },
ForeignKey: "role_id",
LabelColumn: "name",
WritableForeignKey: true,
}}
}
// usersLang prefixes the phrase keys of the Administrators screen.
const usersLang = "backend::lang.user."
// The Administrators form's protected fields.
const (
usersRoleField = "role"
usersPermissionsField = "permissions"
usersSuperuserField = "is_superuser"
)
// db is the application pool, or nil without one.
func (c usersAdmin) db() *gorm.DB {
app := c.application()
if app == nil {
return nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok {
return nil
}
return gdb
}
// requestDB is the write transaction on ctx, else the application pool.
func (c usersAdmin) requestDB(ctx context.Context) *gorm.DB {
if tx, ok := TxFromContext(ctx); ok {
return tx
}
return c.db()
}
// translator is the application's translator, or nil.
func (c usersAdmin) translator() *phrasebook.Translator {
app := c.application()
if app == nil {
return nil
}
tr, _ := app.Lookup[*phrasebook.Translator]()
return tr
}
// asBackendUser asserts a hook's model.
func asBackendUser(model any) (*BackendUser, error) {
user, ok := model.(*BackendUser)
if !ok || user == nil {
return nil, errors.New("cabana: administrators: record is not a backend user")
}
return user, nil
}
// ListExtendQuery hides superusers from an administrator who does not act
// as a superuser (D-03): they are never listed, and a bulk selection never
// resolves to one.
func (usersAdmin) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
return scopeSuperusers(ctx, db)
}
// FormExtendQuery applies the same scope to every record lookup, so a
// superuser's id is not found (404) for anyone else: show, the preview
// partial, update, delete and record actions.
func (usersAdmin) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
return scopeSuperusers(ctx, db)
}
func scopeSuperusers(ctx context.Context, db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
principal, _ := bouncer.User(ctx)
if actsAsSuperuser(principal) {
return db
}
return db.Where("backend_users.is_superuser = ?", false)
}
// extendRecordData sends the stored superuser flag to a superuser. The
// is_superuser switch is a virtual field (the column is protected), and the
// admin sends every field on save: without its stored value an untouched
// save would demote a superuser.
func (usersAdmin) extendRecordData(ctx context.Context, record any, data map[string]any) {
user, ok := record.(*BackendUser)
if !ok || user == nil {
return
}
if principal, _ := bouncer.User(ctx); actsAsSuperuser(principal) {
data[usersSuperuserField] = user.IsSuperuser
}
}
// hiddenSchemaFields removes the superuser field, column and filter for an
// administrator who does not act as a superuser (D-03).
func (usersAdmin) hiddenSchemaFields(ctx context.Context) map[string]bool {
if principal, _ := bouncer.User(ctx); actsAsSuperuser(principal) {
return nil
}
return map[string]bool{usersSuperuserField: true}
}
// FormVirtualFields are the password, its confirmation and the superuser
// switch: none is filled into the model; the Form hooks write them after
// their checks.
func (usersAdmin) FormVirtualFields() []string {
return []string{"password", "password_confirmation", usersSuperuserField}
}
// FormRules require a confirmed password of at least 4 characters on create
// and take one on update only when it is typed (D-11). Login and email
// uniqueness is checked by checkIdentity, deleted administrators included.
func (usersAdmin) FormRules(_ context.Context, op string) map[string]string {
password := "nullable|min:4|confirmed"
if op == "create" {
password = "required|min:4|confirmed"
}
return map[string]string{
"login": "required|max:255",
"email": "required|email|max:255",
"password": password,
}
}
// FilterOptions serves the role filter's choices: every role by name.
func (c usersAdmin) FilterOptions(scope string) []pact.Option {
if scope != filterByRole {
return nil
}
db := c.db()
if db == nil {
return nil
}
var roles []BackendUserRole
if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&roles).Error; err != nil {
return nil
}
out := make([]pact.Option, 0, len(roles))
for _, role := range roles {
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(role.ID), 10), Label: role.Name})
}
return out
}
// AdminRelationLocks locks, for an administrator who does not act as a
// superuser, every role whose grants (stored and code-declared) the
// administrator does not hold themselves (D-07): it cannot be assigned, and
// an administrator who holds one keeps it unchanged.
func (c usersAdmin) AdminRelationLocks(ctx context.Context, field string) (RelationLock, error) {
if field != usersRoleField {
return RelationLock{}, nil
}
principal, _ := bouncer.User(ctx)
if actsAsSuperuser(principal) {
return RelationLock{}, nil
}
db := c.requestDB(ctx)
if db == nil {
return RelationLock{}, errors.New("cabana: administrators: database handle is missing")
}
var roles []BackendUserRole
if err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Order("id ASC").Find(&roles).Error; err != nil {
return RelationLock{}, err
}
var held map[string]bool
if principal != nil {
held = principal.PermissionGrants
}
var ids []uint
for _, role := range roles {
if !grantsCovered(held, roleGrantSet(c.registry(), role)) {
ids = append(ids, role.ID)
}
}
return RelationLock{IDs: ids, Message: usersLang + "role_ceiling"}, nil
}
// permissionComments are the editor comments of the admin's own permissions.
var permissionComments = map[string]string{
PermissionManageUsers: "backend::lang.permissions.manage_users_comment",
PermissionManageRoles: "backend::lang.permissions.manage_roles_comment",
}
// catalogOptions lists the permission catalog in registration order; a
// code the administrator on ctx may not grant is locked (D-07).
func catalogOptions(ctx context.Context, reg *Registry) []PermissionOption {
principal, _ := bouncer.User(ctx)
superuser := actsAsSuperuser(principal)
var held map[string]bool
if principal != nil {
held = principal.PermissionGrants
}
if reg == nil {
return nil
}
out := make([]PermissionOption, 0, len(reg.permissionOrder))
for _, code := range reg.permissionOrder {
permission := reg.permissions[code]
label := permission.Label
if label == "" {
label = code
}
out = append(out, PermissionOption{
Code: code,
Label: label,
Tab: permission.Tab,
Comment: permissionComments[code],
Locked: !superuser && !granted(held, code),
})
}
return out
}
// AdminPermissionOptions offers the whole permission catalog on the
// per-administrator override editor (mode radio).
func (c usersAdmin) AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error) {
if field != usersPermissionsField {
return nil, fmt.Errorf("cabana: administrators: unknown permission field %q", field)
}
return catalogOptions(ctx, c.registry()), nil
}
// AdminPermissionValues decodes the administrator's stored overrides: 1
// (allow) and -1 (deny); any other stored value is left out.
func (usersAdmin) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
user, ok := record.(*BackendUser)
if field != usersPermissionsField || !ok || user == nil {
return nil, fmt.Errorf("cabana: administrators: cannot read permission field %q", field)
}
return decodePermissionValues(user.Permissions, true), nil
}
// AdminSetPermissionValues stores the override set on the record; the save
// writes the row. On the editor's own record any change is refused (D-08).
func (usersAdmin) AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error {
user, ok := record.(*BackendUser)
if field != usersPermissionsField || !ok || user == nil {
return fmt.Errorf("cabana: administrators: cannot write permission field %q", field)
}
if principal, _ := bouncer.User(ctx); principal != nil && user.ID != 0 && principal.ID == user.ID {
if !samePermissionValues(decodePermissionValues(user.Permissions, true), values) {
return &ForbiddenError{Message: usersLang + "self_privileged", Details: map[string]any{usersPermissionsField: []string{usersLang + "self_privileged"}}}
}
}
encoded, err := encodePermissionValues(values)
if err != nil {
return err
}
user.Permissions = encoded
return nil
}
// decodePermissionValues reads a stored permissions JSON object into code
// to value: 1 for a grant and, when deny is set, -1 for a deny. Malformed
// JSON and any other value read as absent.
func decodePermissionValues(raw string, deny bool) map[string]int {
out := map[string]int{}
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
return out
}
var decoded map[string]any
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
return out
}
for code, value := range decoded {
switch {
case truthyGrant(value):
out[code] = 1
case deny && denyGrant(value):
out[code] = -1
}
}
return out
}
// denyGrant reports a stored -1.
func denyGrant(value any) bool {
switch v := value.(type) {
case float64:
return v == -1
case string:
return strings.TrimSpace(v) == "-1"
case json.Number:
return v.String() == "-1"
}
return false
}
func samePermissionValues(a, b map[string]int) bool {
count := 0
for code, n := range b {
if n == 0 {
continue
}
if a[code] != n {
return false
}
count++
}
return count == len(a)
}
// encodePermissionValues is the compact JSON object of the non-zero values,
// "{}" when there are none.
func encodePermissionValues(values map[string]int) (string, error) {
out := make(map[string]int, len(values))
for code, n := range values {
if n != 0 {
out[code] = n
}
}
raw, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(raw), nil
}
// toggleOn reads a switch value as the admin sends it.
func toggleOn(value any) bool {
switch v := value.(type) {
case bool:
return v
case string:
return v == "1" || strings.EqualFold(v, "true")
case json.Number:
return v.String() == "1"
case float64:
return v == 1
}
return false
}
// FormBeforeCreate checks and writes the protected values of a new
// administrator: only a superuser may send the superuser switch, whatever
// its value (D-05); the account starts activated, as admin:create creates
// it; the password is hashed at admin.password.bcrypt_cost (D-11); and the
// login and email must be free. No mail is sent (D-13).
func (c usersAdmin) FormBeforeCreate(ctx context.Context, model any) error {
user, err := asBackendUser(model)
if err != nil {
return err
}
principal, _ := bouncer.User(ctx)
values, _ := VirtualFieldsFromContext(ctx)
if value, sent := values[usersSuperuserField]; sent {
if !actsAsSuperuser(principal) {
return &ForbiddenError{Message: usersLang + "superuser_forbidden", Details: map[string]any{usersSuperuserField: []string{usersLang + "superuser_forbidden"}}}
}
user.IsSuperuser = toggleOn(value)
} else {
user.IsSuperuser = false
}
now := time.Now().UTC()
user.IsActivated = true
user.ActivatedAt = &now
if _, err := c.storePassword(ctx, user); err != nil {
return err
}
return c.checkIdentity(ctx, user)
}
// FormBeforeUpdate runs before the submitted role and permissions are
// applied, while the model still holds the stored role, flag and overrides
// (protected columns are never filled). It snapshots the role for
// FormAfterUpdate, refuses a change to an administrator who outranks the
// editor (D-19), checks the superuser switch (D-05, D-08, D-09), hashes a
// typed password for another administrator and ends their sessions (D-11,
// D-12; their own password is changed in My account, D-20), and checks the
// login and email.
func (c usersAdmin) FormBeforeUpdate(ctx context.Context, model any) error {
user, err := asBackendUser(model)
if err != nil {
return err
}
principal, _ := bouncer.User(ctx)
self := principal != nil && principal.ID == user.ID
if user.RoleID != nil {
prior := *user.RoleID
user.priorRoleID = &prior
} else {
user.priorRoleID = nil
}
user.priorLoaded = true
if !self {
if err := guardOutranked(ctx, c.registry(), principal, user); err != nil {
return err
}
}
values, _ := VirtualFieldsFromContext(ctx)
if value, sent := values[usersSuperuserField]; sent {
superuserDetails := func(key string) map[string]any {
return map[string]any{usersSuperuserField: []string{key}}
}
if !actsAsSuperuser(principal) {
return &ForbiddenError{Message: usersLang + "superuser_forbidden", Details: superuserDetails(usersLang + "superuser_forbidden")}
}
want := toggleOn(value)
if self && want != user.IsSuperuser {
return &ForbiddenError{Message: usersLang + "self_privileged", Details: superuserDetails(usersLang + "self_privileged")}
}
if user.IsSuperuser && !want {
if err := guardLastSuperuser(ctx, user); err != nil {
var refused *ForbiddenError
if errors.As(err, &refused) {
return &ForbiddenError{Message: refused.Message, Details: superuserDetails(refused.Message)}
}
return err
}
}
user.IsSuperuser = want
}
if plain := submittedPassword(ctx); plain != "" && self {
return &ForbiddenError{Message: usersLang + "own_password", Details: map[string]any{"password": []string{usersLang + "own_password"}}}
}
changed, err := c.storePassword(ctx, user)
if err != nil {
return err
}
if changed {
cutoff := revocationCutoff()
user.TokensValidAfter = &cutoff
}
return c.checkIdentity(ctx, user)
}
// FormAfterUpdate refuses a change of the editor's own role (D-08). The
// submitted role is assigned after FormBeforeUpdate, so it is compared here,
// with the row written: the refusal rolls the save back.
func (c usersAdmin) FormAfterUpdate(ctx context.Context, model any) error {
user, err := asBackendUser(model)
if err != nil {
return err
}
principal, _ := bouncer.User(ctx)
if !user.priorLoaded || principal == nil || principal.ID != user.ID {
return nil
}
if !sameRoleID(user.priorRoleID, user.RoleID) {
return &ForbiddenError{Message: usersLang + "self_privileged", Details: map[string]any{usersRoleField: []string{usersLang + "self_privileged"}}}
}
return nil
}
func sameRoleID(a, b *uint) bool {
switch {
case a == nil || *a == 0:
return b == nil || *b == 0
case b == nil:
return false
}
return *a == *b
}
// submittedPassword is the password typed on the form, "" when none.
func submittedPassword(ctx context.Context) string {
values, _ := VirtualFieldsFromContext(ctx)
plain, _ := values["password"].(string)
return plain
}
// storePassword hashes a typed password onto user and reports whether one
// was typed. A password over bcrypt's 72-byte limit is a 422 on the field.
func (c usersAdmin) storePassword(ctx context.Context, user *BackendUser) (bool, error) {
plain := submittedPassword(ctx)
if plain == "" {
return false, nil
}
if len(plain) > passwordMaxBytes {
message := accountMessage(ctx, c.translator(), "max.string", "password", map[string]string{"max": strconv.Itoa(passwordMaxBytes)})
return false, &ValidationError{Details: map[string]any{"password": []string{message}}}
}
hash, err := bouncer.HashPassword(adminBcryptCost(c.application()), plain)
if err != nil {
return false, err
}
user.Password = hash
return true, nil
}
// checkIdentity refuses a login or email another administrator already
// uses, in either field and ignoring case, deleted administrators included
// (the database keeps their values unique), as admin:create does. A value
// only a deleted administrator holds says so, so the editor can restore
// that account instead.
func (c usersAdmin) checkIdentity(ctx context.Context, user *BackendUser) error {
db := c.requestDB(ctx)
if db == nil {
return errors.New("cabana: administrators: database handle is missing")
}
login := strings.ToLower(strings.TrimSpace(user.Login))
email := strings.ToLower(strings.TrimSpace(user.Email))
var holders []BackendUser
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
Select("id", "login", "email", "deleted_at").
Where("(lower(login) IN (?, ?) OR lower(email) IN (?, ?)) AND id <> ?", login, email, login, email, user.ID).
Find(&holders).Error
if err != nil {
return err
}
details := map[string]any{}
for _, field := range []struct{ name, value string }{{"login", login}, {"email", email}} {
if field.value == "" {
continue
}
held, active := false, false
for _, holder := range holders {
if strings.ToLower(holder.Login) == field.value || strings.ToLower(holder.Email) == field.value {
held = true
active = active || !holder.DeletedAt.Valid
}
}
switch {
case !held:
case active:
details[field.name] = []string{usersLang + field.name + "_taken"}
default:
details[field.name] = []string{usersLang + field.name + "_taken_deleted"}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}