- min and max compare the characters of a string or a number's decimal form and the element count of a slice or map on any field without numeric or integer (Laravel's getSize), with the min.array and max.array messages for lists - blank values, booleans, dates and byte slices are not measured; a malformed bound is an error on every non-numeric field - reverse the 261104-f4x pin, prove it through the cabana settings save sequence, and update the README, docs and the storePassword comment
941 lines
32 KiB
Go
941 lines
32 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"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{}
|
|
_ PermissionRecordLockProvider = usersAdmin{}
|
|
_ recordDataExtender = usersAdmin{}
|
|
_ schemaFieldHider = usersAdmin{}
|
|
_ pact.ListRowStates = usersAdmin{}
|
|
_ pact.FormBeforeDelete = usersAdmin{}
|
|
_ pact.FormAfterDelete = usersAdmin{}
|
|
_ pact.HasAdminRecordActions = usersAdmin{}
|
|
_ pact.HasAdminBulkActions = usersAdmin{}
|
|
_ pact.AdminPartialData = 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 includes deleted administrators (WinterCMS's
|
|
// withTrashed), so they are listed with their row state and can be
|
|
// restored, and 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 scopeAdministrators(ctx, db)
|
|
}
|
|
|
|
// FormExtendQuery applies the same scope to every record lookup, so a
|
|
// deleted administrator opens and 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 scopeAdministrators(ctx, db)
|
|
}
|
|
|
|
func scopeAdministrators(ctx context.Context, db *gorm.DB) *gorm.DB {
|
|
if db == nil {
|
|
return db
|
|
}
|
|
db = db.Unscoped()
|
|
principal, _ := bouncer.User(ctx)
|
|
if actsAsSuperuser(principal) {
|
|
return db
|
|
}
|
|
return db.Where("backend_users.is_superuser = ?", false)
|
|
}
|
|
|
|
// ListRowStates marks a deleted administrator deleted and a deactivated one
|
|
// disabled; a row may carry both.
|
|
func (usersAdmin) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) {
|
|
out := make([][]pact.RowState, len(records))
|
|
for i, record := range records {
|
|
user, err := asBackendUser(record)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user.DeletedAt.Valid {
|
|
out[i] = append(out[i], pact.RowStateDeleted)
|
|
}
|
|
if !user.IsActivated {
|
|
out[i] = append(out[i], pact.RowStateDisabled)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// FormBeforeDelete refuses deleting yourself (D-08), an administrator who
|
|
// outranks the editor (D-19) and the last active superuser (D-09). The
|
|
// framework calls it for the form's delete and once per row inside the bulk
|
|
// delete's transaction, so one refused row rolls the whole selection back.
|
|
func (c usersAdmin) FormBeforeDelete(ctx context.Context, model any) error {
|
|
user, err := asBackendUser(model)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
if principal != nil && principal.ID == user.ID {
|
|
return &ForbiddenError{Message: usersLang + "self_delete"}
|
|
}
|
|
if err := guardOutranked(ctx, c.registry(), principal, user); err != nil {
|
|
return err
|
|
}
|
|
return guardLastSuperuser(ctx, user)
|
|
}
|
|
|
|
// FormAfterDelete ends the deleted administrator's sessions (D-12). The
|
|
// delete itself stays the framework's soft delete through deleted_at: a
|
|
// backend user is never hard-deleted, so tokens, linked_by references and
|
|
// attachments keep valid foreign keys (D-10). Personal API tokens stop
|
|
// because the guard refuses a deleted administrator.
|
|
func (c usersAdmin) FormAfterDelete(ctx context.Context, model any) error {
|
|
user, err := asBackendUser(model)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tx, ok := TxFromContext(ctx)
|
|
if !ok {
|
|
return errors.New("cabana: administrators: a delete needs its transaction")
|
|
}
|
|
cutoff := revocationCutoff()
|
|
if err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
|
Model(&BackendUser{}).Where("id = ?", user.ID).
|
|
Update("tokens_valid_after", cutoff).Error; err != nil {
|
|
return err
|
|
}
|
|
fireSubjectChanged(ctx, c.application(), pact.MCPSubjectDeleted, user.ID)
|
|
return nil
|
|
}
|
|
|
|
// usersLifecycle is one lifecycle action of the Administrators screen,
|
|
// shared by its record and bulk forms.
|
|
type usersLifecycle struct {
|
|
name string
|
|
// applies is the action's Applies for a record; self is true on the
|
|
// editor's own record.
|
|
applies func(user *BackendUser, self bool) bool
|
|
// run checks and writes one administrator through the action's
|
|
// transaction.
|
|
run func(ctx context.Context, c usersAdmin, db *gorm.DB, editor *bouncer.Principal, user *BackendUser) error
|
|
}
|
|
|
|
func (c usersAdmin) lifecycles() []usersLifecycle {
|
|
return []usersLifecycle{
|
|
{
|
|
name: "activate",
|
|
applies: func(user *BackendUser, _ bool) bool {
|
|
return !user.DeletedAt.Valid && !user.IsActivated
|
|
},
|
|
run: func(ctx context.Context, c usersAdmin, db *gorm.DB, editor *bouncer.Principal, user *BackendUser) error {
|
|
if err := guardOutranked(ctx, c.registry(), editor, user); err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC()
|
|
return db.Model(&BackendUser{}).Where("id = ?", user.ID).
|
|
Updates(map[string]any{"is_activated": true, "activated_at": now}).Error
|
|
},
|
|
},
|
|
{
|
|
name: "deactivate",
|
|
// Deactivate is not offered on your own record; the run still
|
|
// refuses it (a bulk selection may hold it).
|
|
applies: func(user *BackendUser, self bool) bool {
|
|
return !user.DeletedAt.Valid && user.IsActivated && !self
|
|
},
|
|
run: func(ctx context.Context, c usersAdmin, db *gorm.DB, editor *bouncer.Principal, user *BackendUser) error {
|
|
if editor != nil && editor.ID == user.ID {
|
|
return &ForbiddenError{Message: usersLang + "self_deactivate"}
|
|
}
|
|
if err := guardOutranked(ctx, c.registry(), editor, user); err != nil {
|
|
return err
|
|
}
|
|
if err := guardLastSuperuser(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
if err := db.Model(&BackendUser{}).Where("id = ?", user.ID).
|
|
Updates(map[string]any{"is_activated": false, "tokens_valid_after": revocationCutoff()}).Error; err != nil {
|
|
return err
|
|
}
|
|
// A deactivated administrator can no longer act anywhere,
|
|
// an MCP session included.
|
|
fireSubjectChanged(ctx, c.application(), pact.MCPSubjectDeleted, user.ID)
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
name: "restore",
|
|
applies: func(user *BackendUser, _ bool) bool {
|
|
return user.DeletedAt.Valid
|
|
},
|
|
run: func(ctx context.Context, c usersAdmin, db *gorm.DB, editor *bouncer.Principal, user *BackendUser) error {
|
|
if err := guardOutranked(ctx, c.registry(), editor, user); err != nil {
|
|
return err
|
|
}
|
|
return db.Model(&BackendUser{}).Where("id = ?", user.ID).
|
|
Update("deleted_at", nil).Error
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// actionDB is the action's write transaction as a fresh, unscoped session
|
|
// that every query starts from anew. The final Session matters: Unscoped
|
|
// returns an instance whose conditions accumulate, so a bulk action's
|
|
// second "id = ?" update would otherwise match no row.
|
|
func actionDB(ctx context.Context) (*gorm.DB, error) {
|
|
tx, ok := TxFromContext(ctx)
|
|
if !ok {
|
|
return nil, errors.New("cabana: administrators: an action needs its transaction")
|
|
}
|
|
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().Session(&gorm.Session{}), nil
|
|
}
|
|
|
|
// AdminRecordActions are Activate, Deactivate and Restore on the preview
|
|
// (D-10), each offered only while it applies; at most one applies at a time.
|
|
// Every refusal is a ForbiddenError with a message (the admin's toast shows
|
|
// only the message).
|
|
func (c usersAdmin) AdminRecordActions() []pact.AdminRecordAction {
|
|
lifecycles := c.lifecycles()
|
|
out := make([]pact.AdminRecordAction, 0, len(lifecycles))
|
|
for _, lifecycle := range lifecycles {
|
|
lifecycle := lifecycle
|
|
out = append(out, pact.AdminRecordAction{
|
|
Name: lifecycle.name,
|
|
Label: usersLang + lifecycle.name,
|
|
Confirm: usersLang + lifecycle.name + "_confirm",
|
|
Permissions: []string{PermissionManageUsers},
|
|
Applies: func(ctx context.Context, record any) (bool, error) {
|
|
user, err := asBackendUser(record)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
return lifecycle.applies(user, principal != nil && principal.ID == user.ID), nil
|
|
},
|
|
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
|
user, err := asBackendUser(in.Record)
|
|
if err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
db, err := actionDB(ctx)
|
|
if err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
if err := lifecycle.run(ctx, c, db, principal, user); err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
return pact.AdminRecordActionResult{Message: usersLang + lifecycle.name + "d"}, nil
|
|
},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// AdminBulkActions are Activate, Deactivate and Restore on the selected
|
|
// rows. A row the action does not apply to is skipped and not counted, the
|
|
// editor's own row refuses a deactivate, and one refused row rolls the
|
|
// whole selection back.
|
|
func (c usersAdmin) AdminBulkActions() []pact.AdminBulkAction {
|
|
lifecycles := c.lifecycles()
|
|
out := make([]pact.AdminBulkAction, 0, len(lifecycles))
|
|
for _, lifecycle := range lifecycles {
|
|
lifecycle := lifecycle
|
|
out = append(out, pact.AdminBulkAction{
|
|
Name: lifecycle.name,
|
|
Label: usersLang + lifecycle.name,
|
|
Confirm: usersLang + lifecycle.name + "_selected_confirm",
|
|
Permissions: []string{PermissionManageUsers},
|
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
|
db, err := actionDB(ctx)
|
|
if err != nil {
|
|
return pact.AdminBulkActionResult{}, err
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
affected := 0
|
|
for _, record := range in.Records {
|
|
user, err := asBackendUser(record)
|
|
if err != nil {
|
|
return pact.AdminBulkActionResult{}, err
|
|
}
|
|
// The editor's own row is checked by run, not skipped,
|
|
// so a deactivate that selects it is refused.
|
|
if !lifecycle.applies(user, false) {
|
|
continue
|
|
}
|
|
if err := lifecycle.run(ctx, c, db, principal, user); err != nil {
|
|
return pact.AdminBulkActionResult{}, err
|
|
}
|
|
affected++
|
|
}
|
|
result := pact.AdminBulkActionResult{Affected: affected}
|
|
if affected == len(in.Records) {
|
|
result.Message = usersLang + lifecycle.name + "d_selected"
|
|
}
|
|
return result, nil
|
|
},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// usersHintView is the view model of controllers/users/_hint.htm, the status
|
|
// callout above the preview: the callout kind (danger or warning) and the
|
|
// phrase keys of its title and text. It is empty when no state applies, and
|
|
// the template then renders nothing.
|
|
type usersHintView struct {
|
|
Kind string
|
|
Title string
|
|
Text string
|
|
}
|
|
|
|
// PartialData serves the preview header partial hint: one callout by
|
|
// precedence deleted, deactivated, the editor's own account.
|
|
func (usersAdmin) PartialData(ctx context.Context, name string, record any) (any, error) {
|
|
if name != "hint" {
|
|
return nil, fmt.Errorf("cabana: administrators: unknown partial %q", name)
|
|
}
|
|
user, ok := record.(*BackendUser)
|
|
if !ok || user == nil {
|
|
return usersHintView{}, nil
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
switch {
|
|
case user.DeletedAt.Valid:
|
|
return usersHintView{Kind: "danger", Title: usersLang + "trashed_hint_title", Text: usersLang + "trashed_hint_desc"}, nil
|
|
case !user.IsActivated:
|
|
return usersHintView{Kind: "warning", Title: usersLang + "deactivated_hint_title", Text: usersLang + "deactivated_hint_desc"}, nil
|
|
case principal != nil && principal.ID == user.ID:
|
|
return usersHintView{Kind: "warning", Title: usersLang + "self_hint_title", Text: usersLang + "self_hint_desc"}, nil
|
|
}
|
|
return usersHintView{}, nil
|
|
}
|
|
|
|
// 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, and
|
|
// the deleted filter's two choices.
|
|
func (c usersAdmin) FilterOptions(scope string) []pact.Option {
|
|
if scope == filterTrashed {
|
|
return []pact.Option{
|
|
{Value: trashedHide, Label: usersLang + "filter_deleted_hide"},
|
|
{Value: trashedOnly, Label: usersLang + "filter_deleted_only"},
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// AdminPermissionRecordLocks locks every offered permission on the
|
|
// editor's own record, shown with its stored override (1, -1, or 0 for
|
|
// inherit) and the locked_self note (D-08); other records have none.
|
|
// AdminSetPermissionValues still refuses a change on the own record.
|
|
func (c usersAdmin) AdminPermissionRecordLocks(ctx context.Context, field string, record any) ([]PermissionRecordLock, error) {
|
|
user, ok := record.(*BackendUser)
|
|
if field != usersPermissionsField || !ok || user == nil {
|
|
return nil, fmt.Errorf("cabana: administrators: cannot read permission field %q", field)
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
if principal == nil || user.ID == 0 || principal.ID != user.ID {
|
|
return nil, nil
|
|
}
|
|
stored := decodePermissionValues(user.Permissions, true)
|
|
options := catalogOptions(ctx, c.registry())
|
|
out := make([]PermissionRecordLock, 0, len(options))
|
|
for _, option := range options {
|
|
out = append(out, PermissionRecordLock{Code: option.Code, Value: stored[option.Code], Note: permissionLockedSelfKey})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// permissionLockedSelfKey is the note of every permission on the editor's
|
|
// own Administrators record.
|
|
const permissionLockedSelfKey = "backend::lang.permissioneditor.locked_self"
|
|
|
|
// 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.priorPermissions = user.Permissions
|
|
user.priorSuperuser = user.IsSuperuser
|
|
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. A save that changed
|
|
// the administrator's role, own permissions or superuser flag then fires
|
|
// pact.MCPSubjectChanged (permissions) for them.
|
|
func (c usersAdmin) FormAfterUpdate(ctx context.Context, model any) error {
|
|
user, err := asBackendUser(model)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !user.priorLoaded {
|
|
return nil
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
if principal != nil && principal.ID == user.ID && !sameRoleID(user.priorRoleID, user.RoleID) {
|
|
return &ForbiddenError{Message: usersLang + "self_privileged", Details: map[string]any{usersRoleField: []string{usersLang + "self_privileged"}}}
|
|
}
|
|
if !sameRoleID(user.priorRoleID, user.RoleID) || user.priorPermissions != user.Permissions || user.priorSuperuser != user.IsSuperuser {
|
|
fireSubjectChanged(ctx, c.application(), pact.MCPSubjectPermissions, user.ID)
|
|
}
|
|
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
|
|
}
|
|
|
|
// adminPasswordMinChars is the shortest password the Administrators form
|
|
// accepts, in characters (D-11).
|
|
const adminPasswordMinChars = 4
|
|
|
|
// storePassword hashes a typed password onto user and reports whether one
|
|
// was typed. A password under adminPasswordMinChars characters or over
|
|
// bcrypt's 72-byte limit is a 422 on the field. The form's min:4 rule
|
|
// already refuses a shorter password in lagoon.Validate before this hook
|
|
// runs; the hook repeats the check so it never hashes one, and adds bcrypt's
|
|
// 72-byte limit.
|
|
func (c usersAdmin) storePassword(ctx context.Context, user *BackendUser) (bool, error) {
|
|
plain := submittedPassword(ctx)
|
|
if plain == "" {
|
|
return false, nil
|
|
}
|
|
if utf8.RuneCountInString(plain) < adminPasswordMinChars {
|
|
message := accountMessage(ctx, c.translator(), "min.string", "password", map[string]string{"min": strconv.Itoa(adminPasswordMinChars)})
|
|
return false, &ValidationError{Details: map[string]any{"password": []string{message}}}
|
|
}
|
|
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
|
|
}
|