diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index 62c8cad3..b121c453 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -68,6 +68,8 @@ func (p *BlogPlugin) Navigation() []pact.NavigationItem { A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open: a main menu item the administrator may not open is dropped even when one of its side-menu entries would pass, and an allowed item that links to a controller the administrator cannot open links to its first openable side-menu entry instead. A plugin can also hide an entry per request with `pact.NavigationItem.Visible` (for a screen whose feature is turned off in its settings); the same drop and relink rules apply. `cabana.Allows` is the check and follows Winter's `hasAnyAccess` for JWT sessions: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. When the request carries a personal API token with a non-full scope, the scope is checked first (see [API tokens](#api-tokens)). The last lines of the activation example on [Admin controllers](admin-controllers.md) show it. +The admin registers two permissions of its own on the System tab of the permission editor: `backend.manage_users` (`cabana.PermissionManageUsers`) opens the Administrators screen and `backend.manage_roles` (`cabana.PermissionManageRoles`) the Roles screen. Neither is granted to any role in code, so after an upgrade only superusers reach those screens until an administrator grants a code to a role or to an administrator. + An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`. Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminBulkActions`) and record actions (`pact.HasAdminRecordActions`) may each name extra permissions, checked on top of the controller's. An administrator who lacks them does not get the action in the list schema or in a record's `meta.actions`, and posting it answers 403 and is written to the authentication log. A bulk or record action that ran is logged with the controller, the action, the administrator and the affected count or record id, without record contents. @@ -135,7 +137,7 @@ Operators manage any user's tokens with `admin:token:create`, `admin:token:list` Every signed-in administrator can edit their own profile on the My account screen, which the SPA opens at `/account` from the user menu. It saves through `PUT /api/v1/account`, the Go form of WinterCMS's `Users::myaccount`. The route always edits the row of the token's administrator; no id is read from the path or the body. Personal API tokens are refused on this route and on the avatar routes. -- **Editable:** first name, last name, email and subtitle, plus the password. The body may hold only `first_name`, `last_name`, `email`, `subtitle`, `current_password`, `password` and `password_confirmation` (`cabana.AdminAccountRequest`). Any other key, such as `login`, `role_id`, `is_superuser`, `permissions` or `is_activated`, answers 422 on that key and nothing is written. The login is shown but cannot be changed here, and role, superuser flag, permissions and activation stay with the operator commands below. +- **Editable:** first name, last name, email and subtitle, plus the password. The body may hold only `first_name`, `last_name`, `email`, `subtitle`, `current_password`, `password` and `password_confirmation` (`cabana.AdminAccountRequest`). Any other key, such as `login`, `role_id`, `is_superuser`, `permissions` or `is_activated`, answers 422 on that key and nothing is written. The login is shown but cannot be changed here. Role, superuser flag, permissions and activation are changed by another administrator on the Administrators screen, or with the operator commands below. - **Email:** required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of another administrator, deleted administrators included, because a sign-in identifier that matches two administrators locks both out. Your own email in a different case is accepted. Changing the email does not ask for the password, as in WinterCMS: the framework sends no password-reset emails, so the email is not a recovery channel. - **Password:** leave it blank to keep the current one. A new password needs `current_password`, at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal `password_confirmation`. A wrong current password answers 422 on `current_password` and is written to the authentication log. A changed password is hashed at `admin.password.bcrypt_cost` and ends every other session: tokens issued before the change are refused. The browser that made the change gets a fresh session cookie and stays signed in; an API client that used a Bearer token has to sign in again. - **Subtitle:** a short line such as a role or position. It is stored where WinterCMS keeps it, under the `subtitle` key of the jsonable `backend_users.metadata` column, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as no subtitle. `GET /api/v1/auth/me` returns it as `subtitle`. @@ -147,6 +149,25 @@ Plugins that show an administrator, such as an author box, read the subtitle wit ## Managing administrators +### The Administrators screen + +Settings → System → Administrators (`summercms.cabana.users`, `cabana.AdministratorsControllerID`) lists the backend administrators and opens a preview of each; it needs `backend.manage_users`. The list searches login, names and email and filters by role, superuser access, activation and last sign-in. The form has two tabs: + +- **Account:** login, email, first and last name, the password with its confirmation, and the role. A password is required when an administrator is created and optional when one is edited (blank keeps the current one); it needs at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal confirmation, and is hashed at `admin.password.bcrypt_cost`. A login or email another administrator already uses, in either field and ignoring case, is refused on the field; when only a deleted administrator holds it, the message says so, so you can restore that account instead. A new administrator is created activated, like `admin:create`, and no mail is sent. +- **Permissions:** the superuser switch and the per-administrator permission overrides (inherit, allow, deny for each code in the catalog), stored in `backend_users.permissions` and applied last over the role's grants. + +The server enforces these rules on every save, whatever the client sends: + +- Only an administrator who is a superuser (signed in, or with a personal API token of full scope) sees superusers at all: for everyone else they are left out of the list, their ids answer 404, and the superuser switch, column and filter are not in the schemas. Sending `is_superuser` without being a superuser is refused with 403, even when the field is hidden. +- An administrator cannot grant more than they hold. Permissions they lack are locked in the override editor (a changed locked value is refused with 403), and roles whose grants (stored and code-declared) they do not hold themselves are locked in the role picker: assigning one is refused, while an administrator who already has such a role keeps it when other fields change. +- An administrator who is not a superuser cannot change, deactivate or delete an administrator whose grants they do not all hold themselves. +- On their own record an administrator may change login, email and names. A change of their own role, superuser flag or permission overrides is refused with 403, and so is a password: an administrator changes their own password in My account, which checks the current one. +- A superuser cannot be demoted when it is the last active superuser. + +Setting the password of another administrator ends that administrator's sessions at once, as `admin:reset-password` does: tokens issued before the change are refused. Their personal API tokens keep working, exactly as after `admin:reset-password`; revoke them with `admin:token:revoke` when they should stop too. + +### Operator commands + The application binary has commands for operators: ```sh diff --git a/modules/cabana/README.md b/modules/cabana/README.md index f0b1ae77..2eb228a1 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -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. diff --git a/modules/cabana/backend_privileges.go b/modules/cabana/backend_privileges.go new file mode 100644 index 00000000..5cefaaa3 --- /dev/null +++ b/modules/cabana/backend_privileges.go @@ -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) +} diff --git a/modules/cabana/backend_screens_smoke_test.go b/modules/cabana/backend_screens_smoke_test.go index ec5f00ae..0a02fd60 100644 --- a/modules/cabana/backend_screens_smoke_test.go +++ b/modules/cabana/backend_screens_smoke_test.go @@ -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) + } +} diff --git a/modules/cabana/backend_users_admin.go b/modules/cabana/backend_users_admin.go index 8ff211c0..e17b1d5f 100644 --- a/modules/cabana/backend_users_admin.go +++ b/modules/cabana/backend_users_admin.go @@ -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 +} diff --git a/modules/cabana/backendscreens/controllers/users/config_filter.yaml b/modules/cabana/backendscreens/controllers/users/config_filter.yaml new file mode 100644 index 00000000..1adb69c8 --- /dev/null +++ b/modules/cabana/backendscreens/controllers/users/config_filter.yaml @@ -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 diff --git a/modules/cabana/backendscreens/controllers/users/config_form.yaml b/modules/cabana/backendscreens/controllers/users/config_form.yaml index b6f9b8ef..b0e00e3f 100644 --- a/modules/cabana/backendscreens/controllers/users/config_form.yaml +++ b/modules/cabana/backendscreens/controllers/users/config_form.yaml @@ -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 diff --git a/modules/cabana/backendscreens/controllers/users/config_list.yaml b/modules/cabana/backendscreens/controllers/users/config_list.yaml index 2c1029cc..d9df9be5 100644 --- a/modules/cabana/backendscreens/controllers/users/config_list.yaml +++ b/modules/cabana/backendscreens/controllers/users/config_list.yaml @@ -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 diff --git a/modules/cabana/backendscreens/models/user/fields.yaml b/modules/cabana/backendscreens/models/user/fields.yaml index 0c97b450..a023c379 100644 --- a/modules/cabana/backendscreens/models/user/fields.yaml +++ b/modules/cabana/backendscreens/models/user/fields.yaml @@ -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 diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index 38719acc..97e98d73 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -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. diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index 762ca60e..1d0fb32d 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -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 { diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 845215ba..d1d1e061 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -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 {