Files
summercms/modules/cabana/backend_users_admin.go
Jakub Zych 9f14cf603c feat(19.7-01): account contributions, prompted record actions and backend subject events
- pact.HasAccountContributions compiled at Activate; GET /account/contributions
  and POST /account/contributions/{name}/actions/{action} behind interactiveOnly
  (AccountContributionView, AccountContributionActionView, AccountActionResultView)
- RecordAction.prompt (RecordActionPrompt) for a pact.AdminRecordAction with a
  Prompt; the record action route accepts payload only for such an action
  (trimmed JSON string, non-empty, capped at max_length) and hands it to Run as
  Value through CRUDService.RecordActionWithValue; a prompt without a label
  fails boot
- fireSubjectChanged: pact.MCPSubjectChanged for revoked tokens, Administrators
  saves that changed role, permissions or superuser, Roles saves that changed
  grants, deactivation and deletion
- phrases backend::lang.form.action_prompt_required/_too_long (en, pl)
- smoke tests TestAccountContributionsSmoke and TestRecordActionPromptSmoke;
  TestRecordActionPromptRun on Postgres; route inventories, CSRF walk and
  OpenAPI conformance cases extended for the two routes
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts; cabana
  README, docs/backend/{admin-controllers,users-and-permissions}.md
2026-10-22 16:31:29 +02:00

940 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 length is checked here
// because the form's min:4 rule is a string rule, which the model validator
// does not apply.
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
}