feat(19.6.1-01): administrator form with server-side privilege rules
- create and edit administrators: password (min 4, max 72 bytes, bcrypt at admin.password.bcrypt_cost), role picker, radio permission overrides, superuser switch; no mail on create - superusers hidden from non-superusers in list, lookups and schemas; is_superuser projected to superusers so an untouched save keeps it - save-path rules: superuser-only switch, role and permission ceilings, no outranking, no own role/flag/overrides/password change, last active superuser not demoted (advisory lock + count in the write tx) - password set for another admin bumps tokens_valid_after - role, superuser, activation and last sign-in list filters
This commit is contained in:
@@ -54,7 +54,7 @@ Use cases:
|
||||
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
|
||||
- Refusals: a lifecycle hook, a relation hook, a bulk, record, toolbar or widget action, a `pact.ReorderAfterSave` hook or a `pact.AdminSingleton` `SingletonRecord` call that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. A `cabana.ValidationError` returned from the same places is answered 422 `validation_failed` with its `Details`, each message that is a translation key (`vendor.plugin::group.key`) translated in the request locale and any other text passed through unchanged. The plugin's error value is never modified, so it may be a shared variable. The surrounding transaction is rolled back. Every other error stays the opaque 500, logged on the server.
|
||||
- Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active.
|
||||
- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in and opens a preview of each, where record actions (the admin's own and contributed ones) are offered. The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin.
|
||||
- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin.
|
||||
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
|
||||
|
||||
184
modules/cabana/backend_privileges.go
Normal file
184
modules/cabana/backend_privileges.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// superuserLockKey is the pg_advisory_xact_lock key every write that could
|
||||
// shrink the set of active superusers takes before it counts the others
|
||||
// (guardLastSuperuser). It is an arbitrary fixed bigint ("summ" "su" in
|
||||
// ASCII); nothing else in the framework uses it. The lock is held until the
|
||||
// write transaction ends.
|
||||
const superuserLockKey int64 = 0x73756d6d00007375
|
||||
|
||||
// actsAsSuperuser reports whether principal holds superuser powers on the
|
||||
// Administrators and Roles screens: a superuser signed in with a session or
|
||||
// with a personal API token of full scope. A superuser's scoped token never
|
||||
// gets superuser-only powers, although Allows lets it through its scope.
|
||||
func actsAsSuperuser(p *bouncer.Principal) bool {
|
||||
return p != nil && p.IsSuperuser && (p.Scope == nil || p.Scope.Full)
|
||||
}
|
||||
|
||||
// roleGrantSet is everything role grants: its stored permissions JSON plus
|
||||
// the code-declared grants for its code, the merge FindByID applies.
|
||||
func roleGrantSet(reg *Registry, role BackendUserRole) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for code := range parseGrants(role.Permissions) {
|
||||
out[code] = true
|
||||
}
|
||||
for code := range reg.rolePermissions(role.Code) {
|
||||
out[code] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// effectiveGrants is the grants user holds: the role's stored and
|
||||
// code-declared grants with the user's own permission overrides applied
|
||||
// last, as FindByID merges them. The role is loaded into its own value
|
||||
// through a fresh session on db, never onto user: GORM's Save would rewrite
|
||||
// role_id from a loaded association.
|
||||
func effectiveGrants(ctx context.Context, db *gorm.DB, reg *Registry, user *BackendUser) (map[string]bool, error) {
|
||||
if user == nil {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
grants := map[string]bool{}
|
||||
if user.RoleID != nil && *user.RoleID != 0 {
|
||||
if db == nil {
|
||||
return nil, errors.New("cabana: effective grants need a database handle")
|
||||
}
|
||||
var role BackendUserRole
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
|
||||
Where("id = ?", *user.RoleID).Limit(1).Find(&role).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role.ID != 0 {
|
||||
grants = roleGrantSet(reg, role)
|
||||
}
|
||||
}
|
||||
out := applyUserPermissions(grants, user.Permissions)
|
||||
if out == nil {
|
||||
out = map[string]bool{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// wildcardGrant reports whether code is a wildcard grant ("acme.*" or
|
||||
// "*.manage").
|
||||
func wildcardGrant(code string) bool {
|
||||
return len(code) > 1 && (strings.HasSuffix(code, "*") || strings.HasPrefix(code, "*"))
|
||||
}
|
||||
|
||||
// grantsCovered reports whether holder holds everything wanted grants. A
|
||||
// plain code must pass granted(holder, code). A wildcard grant in wanted is
|
||||
// covered only by the same wildcard in holder or, for a prefix wildcard
|
||||
// ("acme.blog.*"), a broader prefix wildcard ("acme.*"); it is never handed
|
||||
// to granted, where a wildcard means "any matching grant".
|
||||
func grantsCovered(holder, wanted map[string]bool) bool {
|
||||
for code, on := range wanted {
|
||||
if !on {
|
||||
continue
|
||||
}
|
||||
if !wildcardGrant(code) {
|
||||
if !granted(holder, code) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if holder[code] {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(code, "*") {
|
||||
return false
|
||||
}
|
||||
prefix := strings.TrimSuffix(code, "*")
|
||||
covered := false
|
||||
for key, held := range holder {
|
||||
if !held || len(key) < 2 || !strings.HasSuffix(key, "*") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(prefix, strings.TrimSuffix(key, "*")) {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// guardOutranked refuses a change to target by an editor who does not hold
|
||||
// every grant target holds (D-19): updating, deleting, or running an action
|
||||
// on such an administrator would let the editor take over more privilege
|
||||
// than they have. Superusers and the editor's own record pass; a superuser
|
||||
// target is always out of reach for a non-superuser. The target's grants are
|
||||
// read through the write transaction on ctx.
|
||||
func guardOutranked(ctx context.Context, reg *Registry, editor *bouncer.Principal, target *BackendUser) error {
|
||||
if actsAsSuperuser(editor) || target == nil {
|
||||
return nil
|
||||
}
|
||||
if editor != nil && editor.ID == target.ID {
|
||||
return nil
|
||||
}
|
||||
refused := &ForbiddenError{Message: "backend::lang.user.outranked"}
|
||||
if editor == nil || target.IsSuperuser {
|
||||
return refused
|
||||
}
|
||||
tx, ok := TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("cabana: the outranking check needs the write transaction")
|
||||
}
|
||||
grants, err := effectiveGrants(ctx, tx, reg, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !grantsCovered(editor.PermissionGrants, grants) {
|
||||
return refused
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// guardLastSuperuser refuses a write that would leave no active superuser
|
||||
// (D-09). It answers nil unless target is currently an activated superuser
|
||||
// that is not deleted; then, inside the write transaction on ctx, it takes
|
||||
// the advisory lock superuserLockKey and counts the other activated,
|
||||
// undeleted superusers. Every caller holds target's row lock first and
|
||||
// nothing takes a row lock after the advisory lock, so two concurrent
|
||||
// reductions serialize on the lock without a wait cycle, and the second one
|
||||
// counts after the first committed. Call it before the write changes target.
|
||||
func guardLastSuperuser(ctx context.Context, target *BackendUser) error {
|
||||
if target == nil || !target.IsSuperuser || !target.IsActivated || target.DeletedAt.Valid {
|
||||
return nil
|
||||
}
|
||||
tx, ok := TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("cabana: the last-superuser check needs the write transaction")
|
||||
}
|
||||
db := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx)
|
||||
if err := db.Exec("SELECT pg_advisory_xact_lock(?)", superuserLockKey).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var others int64
|
||||
if err := db.Model(&BackendUser{}).
|
||||
Where("is_superuser AND is_activated AND deleted_at IS NULL AND id <> ?", target.ID).
|
||||
Count(&others).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if others == 0 {
|
||||
return &ForbiddenError{Message: "backend::lang.user.last_superuser"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revocationCutoff is the tokens_valid_after value that ends every session
|
||||
// issued so far, the value admin:reset-password writes.
|
||||
func revocationCutoff() time.Time {
|
||||
return time.Now().UTC().Add(time.Second)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
@@ -258,3 +259,123 @@ func TestBackendScreensPostgresSmoke(t *testing.T) {
|
||||
t.Fatalf("developer settings list administrators: %v", settings["data"])
|
||||
}
|
||||
}
|
||||
|
||||
// errorOf returns a failure envelope's message and details.
|
||||
func errorOf(t *testing.T, rec *httptest.ResponseRecorder) (string, map[string]any) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("error body %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Error.Message, body.Error.Details
|
||||
}
|
||||
|
||||
// expectRefusal asserts a status and that the error message contains want.
|
||||
func (e *screensEnv) expectRefusal(t *testing.T, status int, method, rel, token string, body any, want string) map[string]any {
|
||||
t.Helper()
|
||||
rec := e.call(t, method, rel, token, body)
|
||||
if rec.Code != status {
|
||||
t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String())
|
||||
}
|
||||
message, details := errorOf(t, rec)
|
||||
if want != "" && !strings.Contains(message, want) {
|
||||
t.Fatalf("%s %s message=%q want %q (details %v)", method, rel, message, want, details)
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func (e *screensEnv) storedUser(t *testing.T, id uint) cabana.BackendUser {
|
||||
t.Helper()
|
||||
var user cabana.BackendUser
|
||||
if err := e.gdb.Unscoped().First(&user, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func TestAdministratorsFormSmoke(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
root, rootToken := env.admin(t, "form-root", true, "")
|
||||
|
||||
// Create with password and confirmation (D-11, D-13).
|
||||
login := "form-new-" + env.stamp
|
||||
created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, rootToken, map[string]any{
|
||||
"login": login, "email": login + "@example.test", "first_name": "New", "last_name": "Admin",
|
||||
"password": "pass-1234", "password_confirmation": "pass-1234",
|
||||
})
|
||||
data, _ := created["data"].(map[string]any)
|
||||
newID := uint(data["id"].(float64))
|
||||
stored := env.storedUser(t, newID)
|
||||
if !stored.IsActivated || stored.ActivatedAt == nil || stored.IsSuperuser || !bouncer.CheckPassword(stored.Password, "pass-1234") {
|
||||
t.Fatalf("created admin = %+v", stored)
|
||||
}
|
||||
// A taken login answers 422 on the field.
|
||||
details := env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, usersPath, rootToken, map[string]any{
|
||||
"login": strings.ToUpper(login), "email": "other-" + login + "@example.test",
|
||||
"password": "pass-1234", "password_confirmation": "pass-1234",
|
||||
}, "")
|
||||
if !strings.Contains(fmt.Sprint(details["login"]), "already used") {
|
||||
t.Fatalf("taken login details = %v", details)
|
||||
}
|
||||
|
||||
// Pitfall 1: a superuser reads the stored flag, and an untouched save keeps it.
|
||||
own := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, nil)
|
||||
if data, _ := own["data"].(map[string]any); data["is_superuser"] != true {
|
||||
t.Fatalf("own record data = %v", own["data"])
|
||||
}
|
||||
|
||||
// D-08: the superuser cannot switch off their own flag.
|
||||
env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken,
|
||||
map[string]any{"login": root.Login, "email": root.Email, "is_superuser": false}, "your own role")
|
||||
if !env.storedUser(t, root.ID).IsSuperuser {
|
||||
t.Fatal("the superuser lost the flag")
|
||||
}
|
||||
// D-20: an own password is changed in My account, not here.
|
||||
env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken,
|
||||
map[string]any{"login": root.Login, "email": root.Email, "password": "pass-5678", "password_confirmation": "pass-5678"}, "My account")
|
||||
|
||||
// D-09 (sequential path): with a second superuser, demoting it succeeds.
|
||||
second, _ := env.admin(t, "form-second", true, "")
|
||||
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, second.ID), rootToken,
|
||||
map[string]any{"login": second.Login, "email": second.Email, "is_superuser": false})
|
||||
if env.storedUser(t, second.ID).IsSuperuser {
|
||||
t.Fatal("the second superuser was not demoted")
|
||||
}
|
||||
shown := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, second.ID), rootToken, nil)
|
||||
if data, _ := shown["data"].(map[string]any); data["is_superuser"] != false {
|
||||
t.Fatalf("demoted record data = %v", shown["data"])
|
||||
}
|
||||
|
||||
// A limited administrator holding backend.manage_users (D-03, D-05).
|
||||
limitedUser, limitedToken := env.admin(t, "form-limited", false, `{"backend.manage_users":1}`)
|
||||
peer, _ := env.admin(t, "form-peer", false, `{"backend.manage_users":1}`)
|
||||
env.expectRefusal(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), limitedToken, nil, "")
|
||||
schema := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", limitedToken, nil)
|
||||
if strings.Contains(fmt.Sprint(schema["data"]), "is_superuser") {
|
||||
t.Fatal("the form schema shows is_superuser to a non-superuser")
|
||||
}
|
||||
env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, peer.ID), limitedToken,
|
||||
map[string]any{"login": peer.Login, "email": peer.Email, "is_superuser": false}, "Only a superuser")
|
||||
// The same editor may rename the peer: the hidden field stays out of the body.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, peer.ID), limitedToken,
|
||||
map[string]any{"login": peer.Login, "email": peer.Email, "first_name": "Renamed"})
|
||||
if got := env.storedUser(t, peer.ID); got.FirstName != "Renamed" || got.IsSuperuser {
|
||||
t.Fatalf("renamed peer = %+v", got)
|
||||
}
|
||||
// D-08: a change of the editor's own role is refused after assignment
|
||||
// and rolled back.
|
||||
limited := env.storedUser(t, limitedUser.ID)
|
||||
if limited.RoleID == nil || peer.RoleID == nil {
|
||||
t.Fatalf("fixture roles: %+v %+v", limited, peer)
|
||||
}
|
||||
env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, limited.ID), limitedToken,
|
||||
map[string]any{"login": limited.Login, "email": limited.Email, "role": *peer.RoleID}, "your own role")
|
||||
if got := env.storedUser(t, limited.ID); got.RoleID == nil || *got.RoleID != *limited.RoleID {
|
||||
t.Fatalf("own role changed to %v", got.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,17 @@ 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"
|
||||
)
|
||||
|
||||
@@ -19,11 +25,23 @@ type usersAdmin struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = usersAdmin{}
|
||||
_ pact.AdminPermissioned = usersAdmin{}
|
||||
_ pact.AdminRecordSource = usersAdmin{}
|
||||
_ pact.FormRecordTitle = usersAdmin{}
|
||||
_ FieldRelationProvider = usersAdmin{}
|
||||
_ pact.AdminController = usersAdmin{}
|
||||
_ pact.AdminPermissioned = usersAdmin{}
|
||||
_ pact.AdminRecordSource = usersAdmin{}
|
||||
_ pact.FormRecordTitle = usersAdmin{}
|
||||
_ FieldRelationProvider = usersAdmin{}
|
||||
_ pact.ListExtendQuery = usersAdmin{}
|
||||
_ pact.FormExtendQuery = usersAdmin{}
|
||||
_ pact.FormVirtualFields = usersAdmin{}
|
||||
_ pact.FormRules = usersAdmin{}
|
||||
_ pact.FilterOptions = usersAdmin{}
|
||||
_ pact.FormBeforeCreate = usersAdmin{}
|
||||
_ pact.FormBeforeUpdate = usersAdmin{}
|
||||
_ pact.FormAfterUpdate = usersAdmin{}
|
||||
_ RelationLockProvider = usersAdmin{}
|
||||
_ PermissionEditorProvider = usersAdmin{}
|
||||
_ recordDataExtender = usersAdmin{}
|
||||
_ schemaFieldHider = usersAdmin{}
|
||||
)
|
||||
|
||||
func (usersAdmin) ID() string { return AdministratorsControllerID }
|
||||
@@ -68,3 +86,526 @@ func (usersAdmin) AdminFieldRelations() []FieldRelationContract {
|
||||
WritableForeignKey: true,
|
||||
}}
|
||||
}
|
||||
|
||||
// usersLang prefixes the phrase keys of the Administrators screen.
|
||||
const usersLang = "backend::lang.user."
|
||||
|
||||
// The Administrators form's protected fields.
|
||||
const (
|
||||
usersRoleField = "role"
|
||||
usersPermissionsField = "permissions"
|
||||
usersSuperuserField = "is_superuser"
|
||||
)
|
||||
|
||||
// db is the application pool, or nil without one.
|
||||
func (c usersAdmin) db() *gorm.DB {
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
|
||||
// requestDB is the write transaction on ctx, else the application pool.
|
||||
func (c usersAdmin) requestDB(ctx context.Context) *gorm.DB {
|
||||
if tx, ok := TxFromContext(ctx); ok {
|
||||
return tx
|
||||
}
|
||||
return c.db()
|
||||
}
|
||||
|
||||
// translator is the application's translator, or nil.
|
||||
func (c usersAdmin) translator() *phrasebook.Translator {
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
tr, _ := app.Lookup[*phrasebook.Translator]()
|
||||
return tr
|
||||
}
|
||||
|
||||
// asBackendUser asserts a hook's model.
|
||||
func asBackendUser(model any) (*BackendUser, error) {
|
||||
user, ok := model.(*BackendUser)
|
||||
if !ok || user == nil {
|
||||
return nil, errors.New("cabana: administrators: record is not a backend user")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ListExtendQuery hides superusers from an administrator who does not act
|
||||
// as a superuser (D-03): they are never listed, and a bulk selection never
|
||||
// resolves to one.
|
||||
func (usersAdmin) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
return scopeSuperusers(ctx, db)
|
||||
}
|
||||
|
||||
// FormExtendQuery applies the same scope to every record lookup, so a
|
||||
// superuser's id is not found (404) for anyone else: show, the preview
|
||||
// partial, update, delete and record actions.
|
||||
func (usersAdmin) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
return scopeSuperusers(ctx, db)
|
||||
}
|
||||
|
||||
func scopeSuperusers(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if actsAsSuperuser(principal) {
|
||||
return db
|
||||
}
|
||||
return db.Where("backend_users.is_superuser = ?", false)
|
||||
}
|
||||
|
||||
// extendRecordData sends the stored superuser flag to a superuser. The
|
||||
// is_superuser switch is a virtual field (the column is protected), and the
|
||||
// admin sends every field on save: without its stored value an untouched
|
||||
// save would demote a superuser.
|
||||
func (usersAdmin) extendRecordData(ctx context.Context, record any, data map[string]any) {
|
||||
user, ok := record.(*BackendUser)
|
||||
if !ok || user == nil {
|
||||
return
|
||||
}
|
||||
if principal, _ := bouncer.User(ctx); actsAsSuperuser(principal) {
|
||||
data[usersSuperuserField] = user.IsSuperuser
|
||||
}
|
||||
}
|
||||
|
||||
// hiddenSchemaFields removes the superuser field, column and filter for an
|
||||
// administrator who does not act as a superuser (D-03).
|
||||
func (usersAdmin) hiddenSchemaFields(ctx context.Context) map[string]bool {
|
||||
if principal, _ := bouncer.User(ctx); actsAsSuperuser(principal) {
|
||||
return nil
|
||||
}
|
||||
return map[string]bool{usersSuperuserField: true}
|
||||
}
|
||||
|
||||
// FormVirtualFields are the password, its confirmation and the superuser
|
||||
// switch: none is filled into the model; the Form hooks write them after
|
||||
// their checks.
|
||||
func (usersAdmin) FormVirtualFields() []string {
|
||||
return []string{"password", "password_confirmation", usersSuperuserField}
|
||||
}
|
||||
|
||||
// FormRules require a confirmed password of at least 4 characters on create
|
||||
// and take one on update only when it is typed (D-11). Login and email
|
||||
// uniqueness is checked by checkIdentity, deleted administrators included.
|
||||
func (usersAdmin) FormRules(_ context.Context, op string) map[string]string {
|
||||
password := "nullable|min:4|confirmed"
|
||||
if op == "create" {
|
||||
password = "required|min:4|confirmed"
|
||||
}
|
||||
return map[string]string{
|
||||
"login": "required|max:255",
|
||||
"email": "required|email|max:255",
|
||||
"password": password,
|
||||
}
|
||||
}
|
||||
|
||||
// FilterOptions serves the role filter's choices: every role by name.
|
||||
func (c usersAdmin) FilterOptions(scope string) []pact.Option {
|
||||
if scope != filterByRole {
|
||||
return nil
|
||||
}
|
||||
db := c.db()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var roles []BackendUserRole
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&roles).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]pact.Option, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(role.ID), 10), Label: role.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminRelationLocks locks, for an administrator who does not act as a
|
||||
// superuser, every role whose grants (stored and code-declared) the
|
||||
// administrator does not hold themselves (D-07): it cannot be assigned, and
|
||||
// an administrator who holds one keeps it unchanged.
|
||||
func (c usersAdmin) AdminRelationLocks(ctx context.Context, field string) (RelationLock, error) {
|
||||
if field != usersRoleField {
|
||||
return RelationLock{}, nil
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if actsAsSuperuser(principal) {
|
||||
return RelationLock{}, nil
|
||||
}
|
||||
db := c.requestDB(ctx)
|
||||
if db == nil {
|
||||
return RelationLock{}, errors.New("cabana: administrators: database handle is missing")
|
||||
}
|
||||
var roles []BackendUserRole
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Order("id ASC").Find(&roles).Error; err != nil {
|
||||
return RelationLock{}, err
|
||||
}
|
||||
var held map[string]bool
|
||||
if principal != nil {
|
||||
held = principal.PermissionGrants
|
||||
}
|
||||
var ids []uint
|
||||
for _, role := range roles {
|
||||
if !grantsCovered(held, roleGrantSet(c.registry(), role)) {
|
||||
ids = append(ids, role.ID)
|
||||
}
|
||||
}
|
||||
return RelationLock{IDs: ids, Message: usersLang + "role_ceiling"}, nil
|
||||
}
|
||||
|
||||
// permissionComments are the editor comments of the admin's own permissions.
|
||||
var permissionComments = map[string]string{
|
||||
PermissionManageUsers: "backend::lang.permissions.manage_users_comment",
|
||||
PermissionManageRoles: "backend::lang.permissions.manage_roles_comment",
|
||||
}
|
||||
|
||||
// catalogOptions lists the permission catalog in registration order; a
|
||||
// code the administrator on ctx may not grant is locked (D-07).
|
||||
func catalogOptions(ctx context.Context, reg *Registry) []PermissionOption {
|
||||
principal, _ := bouncer.User(ctx)
|
||||
superuser := actsAsSuperuser(principal)
|
||||
var held map[string]bool
|
||||
if principal != nil {
|
||||
held = principal.PermissionGrants
|
||||
}
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]PermissionOption, 0, len(reg.permissionOrder))
|
||||
for _, code := range reg.permissionOrder {
|
||||
permission := reg.permissions[code]
|
||||
label := permission.Label
|
||||
if label == "" {
|
||||
label = code
|
||||
}
|
||||
out = append(out, PermissionOption{
|
||||
Code: code,
|
||||
Label: label,
|
||||
Tab: permission.Tab,
|
||||
Comment: permissionComments[code],
|
||||
Locked: !superuser && !granted(held, code),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminPermissionOptions offers the whole permission catalog on the
|
||||
// per-administrator override editor (mode radio).
|
||||
func (c usersAdmin) AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error) {
|
||||
if field != usersPermissionsField {
|
||||
return nil, fmt.Errorf("cabana: administrators: unknown permission field %q", field)
|
||||
}
|
||||
return catalogOptions(ctx, c.registry()), nil
|
||||
}
|
||||
|
||||
// AdminPermissionValues decodes the administrator's stored overrides: 1
|
||||
// (allow) and -1 (deny); any other stored value is left out.
|
||||
func (usersAdmin) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
|
||||
user, ok := record.(*BackendUser)
|
||||
if field != usersPermissionsField || !ok || user == nil {
|
||||
return nil, fmt.Errorf("cabana: administrators: cannot read permission field %q", field)
|
||||
}
|
||||
return decodePermissionValues(user.Permissions, true), nil
|
||||
}
|
||||
|
||||
// AdminSetPermissionValues stores the override set on the record; the save
|
||||
// writes the row. On the editor's own record any change is refused (D-08).
|
||||
func (usersAdmin) AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error {
|
||||
user, ok := record.(*BackendUser)
|
||||
if field != usersPermissionsField || !ok || user == nil {
|
||||
return fmt.Errorf("cabana: administrators: cannot write permission field %q", field)
|
||||
}
|
||||
if principal, _ := bouncer.User(ctx); principal != nil && user.ID != 0 && principal.ID == user.ID {
|
||||
if !samePermissionValues(decodePermissionValues(user.Permissions, true), values) {
|
||||
return &ForbiddenError{Message: usersLang + "self_privileged", Details: map[string]any{usersPermissionsField: []string{usersLang + "self_privileged"}}}
|
||||
}
|
||||
}
|
||||
encoded, err := encodePermissionValues(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.Permissions = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodePermissionValues reads a stored permissions JSON object into code
|
||||
// to value: 1 for a grant and, when deny is set, -1 for a deny. Malformed
|
||||
// JSON and any other value read as absent.
|
||||
func decodePermissionValues(raw string, deny bool) map[string]int {
|
||||
out := map[string]int{}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "null" {
|
||||
return out
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
return out
|
||||
}
|
||||
for code, value := range decoded {
|
||||
switch {
|
||||
case truthyGrant(value):
|
||||
out[code] = 1
|
||||
case deny && denyGrant(value):
|
||||
out[code] = -1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// denyGrant reports a stored -1.
|
||||
func denyGrant(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return v == -1
|
||||
case string:
|
||||
return strings.TrimSpace(v) == "-1"
|
||||
case json.Number:
|
||||
return v.String() == "-1"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func samePermissionValues(a, b map[string]int) bool {
|
||||
count := 0
|
||||
for code, n := range b {
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if a[code] != n {
|
||||
return false
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count == len(a)
|
||||
}
|
||||
|
||||
// encodePermissionValues is the compact JSON object of the non-zero values,
|
||||
// "{}" when there are none.
|
||||
func encodePermissionValues(values map[string]int) (string, error) {
|
||||
out := make(map[string]int, len(values))
|
||||
for code, n := range values {
|
||||
if n != 0 {
|
||||
out[code] = n
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// toggleOn reads a switch value as the admin sends it.
|
||||
func toggleOn(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v == "1" || strings.EqualFold(v, "true")
|
||||
case json.Number:
|
||||
return v.String() == "1"
|
||||
case float64:
|
||||
return v == 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormBeforeCreate checks and writes the protected values of a new
|
||||
// administrator: only a superuser may send the superuser switch, whatever
|
||||
// its value (D-05); the account starts activated, as admin:create creates
|
||||
// it; the password is hashed at admin.password.bcrypt_cost (D-11); and the
|
||||
// login and email must be free. No mail is sent (D-13).
|
||||
func (c usersAdmin) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
user, err := asBackendUser(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
values, _ := VirtualFieldsFromContext(ctx)
|
||||
if value, sent := values[usersSuperuserField]; sent {
|
||||
if !actsAsSuperuser(principal) {
|
||||
return &ForbiddenError{Message: usersLang + "superuser_forbidden", Details: map[string]any{usersSuperuserField: []string{usersLang + "superuser_forbidden"}}}
|
||||
}
|
||||
user.IsSuperuser = toggleOn(value)
|
||||
} else {
|
||||
user.IsSuperuser = false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user.IsActivated = true
|
||||
user.ActivatedAt = &now
|
||||
if _, err := c.storePassword(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.checkIdentity(ctx, user)
|
||||
}
|
||||
|
||||
// FormBeforeUpdate runs before the submitted role and permissions are
|
||||
// applied, while the model still holds the stored role, flag and overrides
|
||||
// (protected columns are never filled). It snapshots the role for
|
||||
// FormAfterUpdate, refuses a change to an administrator who outranks the
|
||||
// editor (D-19), checks the superuser switch (D-05, D-08, D-09), hashes a
|
||||
// typed password for another administrator and ends their sessions (D-11,
|
||||
// D-12; their own password is changed in My account, D-20), and checks the
|
||||
// login and email.
|
||||
func (c usersAdmin) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
user, err := asBackendUser(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
self := principal != nil && principal.ID == user.ID
|
||||
if user.RoleID != nil {
|
||||
prior := *user.RoleID
|
||||
user.priorRoleID = &prior
|
||||
} else {
|
||||
user.priorRoleID = nil
|
||||
}
|
||||
user.priorLoaded = true
|
||||
if !self {
|
||||
if err := guardOutranked(ctx, c.registry(), principal, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
values, _ := VirtualFieldsFromContext(ctx)
|
||||
if value, sent := values[usersSuperuserField]; sent {
|
||||
superuserDetails := func(key string) map[string]any {
|
||||
return map[string]any{usersSuperuserField: []string{key}}
|
||||
}
|
||||
if !actsAsSuperuser(principal) {
|
||||
return &ForbiddenError{Message: usersLang + "superuser_forbidden", Details: superuserDetails(usersLang + "superuser_forbidden")}
|
||||
}
|
||||
want := toggleOn(value)
|
||||
if self && want != user.IsSuperuser {
|
||||
return &ForbiddenError{Message: usersLang + "self_privileged", Details: superuserDetails(usersLang + "self_privileged")}
|
||||
}
|
||||
if user.IsSuperuser && !want {
|
||||
if err := guardLastSuperuser(ctx, user); err != nil {
|
||||
var refused *ForbiddenError
|
||||
if errors.As(err, &refused) {
|
||||
return &ForbiddenError{Message: refused.Message, Details: superuserDetails(refused.Message)}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
user.IsSuperuser = want
|
||||
}
|
||||
if plain := submittedPassword(ctx); plain != "" && self {
|
||||
return &ForbiddenError{Message: usersLang + "own_password", Details: map[string]any{"password": []string{usersLang + "own_password"}}}
|
||||
}
|
||||
changed, err := c.storePassword(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
cutoff := revocationCutoff()
|
||||
user.TokensValidAfter = &cutoff
|
||||
}
|
||||
return c.checkIdentity(ctx, user)
|
||||
}
|
||||
|
||||
// FormAfterUpdate refuses a change of the editor's own role (D-08). The
|
||||
// submitted role is assigned after FormBeforeUpdate, so it is compared here,
|
||||
// with the row written: the refusal rolls the save back.
|
||||
func (c usersAdmin) FormAfterUpdate(ctx context.Context, model any) error {
|
||||
user, err := asBackendUser(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if !user.priorLoaded || principal == nil || principal.ID != user.ID {
|
||||
return nil
|
||||
}
|
||||
if !sameRoleID(user.priorRoleID, user.RoleID) {
|
||||
return &ForbiddenError{Message: usersLang + "self_privileged", Details: map[string]any{usersRoleField: []string{usersLang + "self_privileged"}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameRoleID(a, b *uint) bool {
|
||||
switch {
|
||||
case a == nil || *a == 0:
|
||||
return b == nil || *b == 0
|
||||
case b == nil:
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// submittedPassword is the password typed on the form, "" when none.
|
||||
func submittedPassword(ctx context.Context) string {
|
||||
values, _ := VirtualFieldsFromContext(ctx)
|
||||
plain, _ := values["password"].(string)
|
||||
return plain
|
||||
}
|
||||
|
||||
// storePassword hashes a typed password onto user and reports whether one
|
||||
// was typed. A password over bcrypt's 72-byte limit is a 422 on the field.
|
||||
func (c usersAdmin) storePassword(ctx context.Context, user *BackendUser) (bool, error) {
|
||||
plain := submittedPassword(ctx)
|
||||
if plain == "" {
|
||||
return false, nil
|
||||
}
|
||||
if len(plain) > passwordMaxBytes {
|
||||
message := accountMessage(ctx, c.translator(), "max.string", "password", map[string]string{"max": strconv.Itoa(passwordMaxBytes)})
|
||||
return false, &ValidationError{Details: map[string]any{"password": []string{message}}}
|
||||
}
|
||||
hash, err := bouncer.HashPassword(adminBcryptCost(c.application()), plain)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
user.Password = hash
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// checkIdentity refuses a login or email another administrator already
|
||||
// uses, in either field and ignoring case, deleted administrators included
|
||||
// (the database keeps their values unique), as admin:create does. A value
|
||||
// only a deleted administrator holds says so, so the editor can restore
|
||||
// that account instead.
|
||||
func (c usersAdmin) checkIdentity(ctx context.Context, user *BackendUser) error {
|
||||
db := c.requestDB(ctx)
|
||||
if db == nil {
|
||||
return errors.New("cabana: administrators: database handle is missing")
|
||||
}
|
||||
login := strings.ToLower(strings.TrimSpace(user.Login))
|
||||
email := strings.ToLower(strings.TrimSpace(user.Email))
|
||||
var holders []BackendUser
|
||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||
Select("id", "login", "email", "deleted_at").
|
||||
Where("(lower(login) IN (?, ?) OR lower(email) IN (?, ?)) AND id <> ?", login, email, login, email, user.ID).
|
||||
Find(&holders).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
details := map[string]any{}
|
||||
for _, field := range []struct{ name, value string }{{"login", login}, {"email", email}} {
|
||||
if field.value == "" {
|
||||
continue
|
||||
}
|
||||
held, active := false, false
|
||||
for _, holder := range holders {
|
||||
if strings.ToLower(holder.Login) == field.value || strings.ToLower(holder.Email) == field.value {
|
||||
held = true
|
||||
active = active || !holder.DeletedAt.Valid
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !held:
|
||||
case active:
|
||||
details[field.name] = []string{usersLang + field.name + "_taken"}
|
||||
default:
|
||||
details[field.name] = []string{usersLang + field.name + "_taken_deleted"}
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return &ValidationError{Details: details}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
scopes:
|
||||
role:
|
||||
label: backend::lang.user.role_field
|
||||
modelClass: Backend\Models\UserRole
|
||||
nameFrom: name
|
||||
scope: filterByRole
|
||||
is_superuser:
|
||||
label: backend::lang.user.superuser
|
||||
type: switch
|
||||
column: is_superuser
|
||||
activated:
|
||||
label: backend::lang.user.filter_activated
|
||||
type: switch
|
||||
column: is_activated
|
||||
last_login:
|
||||
label: backend::lang.user.last_login
|
||||
type: daterange
|
||||
column: last_login
|
||||
@@ -3,6 +3,10 @@ form: ~/plugins/summercms/cabana/models/user/fields.yaml
|
||||
modelClass: Backend\Models\User
|
||||
defaultRedirect: summercms/cabana/users
|
||||
|
||||
create:
|
||||
redirect: summercms/cabana/users/preview/:id
|
||||
redirectClose: summercms/cabana/users
|
||||
|
||||
update:
|
||||
redirect: summercms/cabana/users/update/:id
|
||||
redirectClose: summercms/cabana/users/preview/:id
|
||||
@@ -10,6 +14,7 @@ update:
|
||||
preview: {}
|
||||
|
||||
messages:
|
||||
create: backend::lang.user.new
|
||||
update: backend::lang.messages.form.update
|
||||
saved: backend::lang.messages.form.saved
|
||||
edit: backend::lang.user.edit
|
||||
|
||||
@@ -5,7 +5,12 @@ recordUrl: summercms/cabana/users/preview/:id
|
||||
noRecordsMessage: backend::lang.user.list_empty
|
||||
recordsPerPage: 20
|
||||
showSetup: true
|
||||
filter: config_filter.yaml
|
||||
|
||||
toolbar:
|
||||
buttons: [create]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
messages:
|
||||
create: backend::lang.user.new
|
||||
|
||||
@@ -19,6 +19,19 @@ fields:
|
||||
type: text
|
||||
span: right
|
||||
tab: backend::lang.user.account
|
||||
password:
|
||||
label: backend::lang.user.password
|
||||
comment: backend::lang.user.password_comment
|
||||
type: password
|
||||
span: left
|
||||
tab: backend::lang.user.account
|
||||
context: [create, update]
|
||||
password_confirmation:
|
||||
label: backend::lang.user.password_confirmation
|
||||
type: password
|
||||
span: right
|
||||
tab: backend::lang.user.account
|
||||
context: [create, update]
|
||||
role:
|
||||
label: backend::lang.user.role_field
|
||||
comment: backend::lang.user.role_comment
|
||||
@@ -33,3 +46,16 @@ fields:
|
||||
span: left
|
||||
tab: backend::lang.user.account
|
||||
context: preview
|
||||
is_superuser:
|
||||
label: backend::lang.user.superuser
|
||||
comment: backend::lang.user.superuser_comment
|
||||
type: switch
|
||||
span: full
|
||||
tab: backend::lang.user.permissions
|
||||
permissions:
|
||||
label: backend::lang.user.permission_overrides
|
||||
comment: backend::lang.user.permission_overrides_comment
|
||||
type: permissioneditor
|
||||
mode: radio
|
||||
span: full
|
||||
tab: backend::lang.user.permissions
|
||||
|
||||
@@ -3,7 +3,9 @@ package cabana
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -70,6 +72,13 @@ type BackendUser struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
|
||||
Role BackendUserRole
|
||||
|
||||
// priorRoleID is the stored role_id the Administrators screen saw
|
||||
// before a save assigned the submitted role (priorLoaded set), so the
|
||||
// save can refuse a change of the editor's own role after the
|
||||
// assignment. Unexported: GORM and the admin's field binding skip it.
|
||||
priorRoleID *uint
|
||||
priorLoaded bool
|
||||
}
|
||||
|
||||
func (BackendUser) TableName() string { return "backend_users" }
|
||||
@@ -82,6 +91,44 @@ func (BackendUser) Fillable() []string {
|
||||
return []string{"login", "email", "first_name", "last_name"}
|
||||
}
|
||||
|
||||
// filterByRole is the Administrators list filter scope that keeps the
|
||||
// holders of one role.
|
||||
const filterByRole = "filterByRole"
|
||||
|
||||
// FilterScopes lists the scope names the Administrators list filters call.
|
||||
func (BackendUser) FilterScopes() []string { return []string{filterByRole} }
|
||||
|
||||
// FilterScope narrows db for the scope name: filterByRole keeps the holders
|
||||
// of the role whose id is value. Any other name, and a value that is not a
|
||||
// role id, match nothing; a nil db is returned as it is.
|
||||
func (BackendUser) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
if name != filterByRole {
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
id, ok := filterID(value)
|
||||
if !ok {
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
return db.Where("backend_users.role_id = ?", id)
|
||||
}
|
||||
|
||||
// filterID reads a filter value as a positive whole number, given as a
|
||||
// number or as its decimal text.
|
||||
func filterID(value any) (uint64, bool) {
|
||||
if value == nil {
|
||||
return 0, false
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
id, err := strconv.ParseUint(text, 10, 32)
|
||||
if err != nil || id == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// Rules are the administrator's admin save rules. The uniqueness of login
|
||||
// and email, deleted administrators included and ignoring case, is checked
|
||||
// by the Administrators screen itself.
|
||||
|
||||
@@ -641,6 +641,16 @@ type actionConflict struct{}
|
||||
|
||||
func (actionConflict) Error() string { return "cabana: action does not apply" }
|
||||
|
||||
// recordDataExtender is implemented by cabana's own admin controllers that
|
||||
// send a value the generic projection leaves out, such as a protected column
|
||||
// a virtual field edits (the Administrators screen's is_superuser switch).
|
||||
// extendRecordData adds keys to data for the principal on ctx. Unexported:
|
||||
// only controllers in this package can implement it, so no other
|
||||
// controller's record payload changes.
|
||||
type recordDataExtender interface {
|
||||
extendRecordData(ctx context.Context, record any, data map[string]any)
|
||||
}
|
||||
|
||||
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
|
||||
// values keyed by field name with their labels, and the stored permissions of
|
||||
// every permissioneditor field as an object (D-16).
|
||||
@@ -658,6 +668,9 @@ func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController,
|
||||
return RecordResult{}, err
|
||||
}
|
||||
if cc != nil {
|
||||
if ext, ok := cc.Controller.(recordDataExtender); ok && ext != nil {
|
||||
ext.extendRecordData(ctx, model, data)
|
||||
}
|
||||
if hook, ok := cc.Controller.(pact.FormRecordTitle); ok && hook != nil {
|
||||
title, err := hook.FormRecordTitle(ctx, tx, model)
|
||||
if err != nil {
|
||||
|
||||
@@ -781,8 +781,12 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||
// action this admin may not run is not offered. A new slice: the
|
||||
// localized view must never share its backing array with the cache.
|
||||
principal, _ := bouncer.User(r.Context())
|
||||
hidden := hiddenSchemaFields(r.Context(), cc)
|
||||
kept := make([]FormField, 0, len(view.Fields))
|
||||
for _, field := range view.Fields {
|
||||
if hidden[field.Name] {
|
||||
continue
|
||||
}
|
||||
if field.Type == "widget" {
|
||||
action, ok := cc.Actions[field.Action]
|
||||
if !ok || !Allows(principal, action.Permissions) {
|
||||
@@ -814,6 +818,28 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// schemaFieldHider is implemented by cabana's own admin controllers that
|
||||
// hide some fields from some administrators (the Administrators screen's
|
||||
// is_superuser for non-superusers). hiddenSchemaFields names the form
|
||||
// fields, list columns and list filters to leave out of this request's
|
||||
// schemas. Unexported, like recordDataExtender.
|
||||
type schemaFieldHider interface {
|
||||
hiddenSchemaFields(ctx context.Context) map[string]bool
|
||||
}
|
||||
|
||||
// hiddenSchemaFields asks cc's controller which schema entries to hide for
|
||||
// the request; nil when it hides none.
|
||||
func hiddenSchemaFields(ctx context.Context, cc *CompiledController) map[string]bool {
|
||||
if cc == nil {
|
||||
return nil
|
||||
}
|
||||
hider, ok := cc.Controller.(schemaFieldHider)
|
||||
if !ok || hider == nil {
|
||||
return nil
|
||||
}
|
||||
return hider.hiddenSchemaFields(ctx)
|
||||
}
|
||||
|
||||
func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
schema := cc.List
|
||||
@@ -848,6 +874,24 @@ func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
view.BulkActions = bulk
|
||||
// Fields a controller hides from this administrator take their
|
||||
// column and filter with them; new slices again.
|
||||
if hidden := hiddenSchemaFields(r.Context(), cc); len(hidden) > 0 {
|
||||
columns := make([]ListColumn, 0, len(view.Columns))
|
||||
for _, column := range view.Columns {
|
||||
if !hidden[column.Key] {
|
||||
columns = append(columns, column)
|
||||
}
|
||||
}
|
||||
view.Columns = columns
|
||||
filters := make([]ListFilter, 0, len(view.Filters))
|
||||
for _, filter := range view.Filters {
|
||||
if !hidden[filter.Name] {
|
||||
filters = append(filters, filter)
|
||||
}
|
||||
}
|
||||
view.Filters = filters
|
||||
}
|
||||
view.Assets = s.controllerAssets(cc)
|
||||
meta := map[string]any{}
|
||||
if view.Meta != nil {
|
||||
|
||||
Reference in New Issue
Block a user