- cabana.PermissionRecordLock and the optional PermissionRecordLockProvider - RecordMeta.PermissionLocks (meta.permissionLocks) on show and save responses, notes localized - save rule: a record-locked code keeps its stored value, a differing present value is 403 - Roles lock code-declared grants (locked_by_code); Administrators lock every override on your own record (locked_self) - SPA: FORM_PERMISSION_LOCKS, locked codes never sent, record-locked rows in the permission editor - regenerated admin OpenAPI and schema.d.ts; docs and README
908 lines
31 KiB
Go
908 lines
31 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{}
|
|
_ 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 (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()
|
|
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
|
Model(&BackendUser{}).Where("id = ?", user.ID).
|
|
Update("tokens_valid_after", cutoff).Error
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return db.Model(&BackendUser{}).Where("id = ?", user.ID).
|
|
Updates(map[string]any{"is_activated": false, "tokens_valid_after": revocationCutoff()}).Error
|
|
},
|
|
},
|
|
{
|
|
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.
|
|
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(), 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.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
|
|
}
|