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 }