From fff19e5e33e5f8be6b3863624d0d788e2ea21ed1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 21 Oct 2026 18:15:54 +0200 Subject: [PATCH] docs(19.6.1): research phase domain --- .../19.6.1-RESEARCH.md | 588 ++++++++++++++++++ 1 file changed, 588 insertions(+) create mode 100644 .planning/phases/19.6.1-backend-administrators-screen/19.6.1-RESEARCH.md diff --git a/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-RESEARCH.md b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-RESEARCH.md new file mode 100644 index 00000000..9b57c636 --- /dev/null +++ b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-RESEARCH.md @@ -0,0 +1,588 @@ +# Phase 19.6.1: Backend Administrators screen - Research + +**Researched:** 2026-10-21 +**Domain:** cabana (admin framework) — generic CRUD reuse, server-side privilege rules, a new `pact` capability +**Confidence:** HIGH for in-repo mechanics (every hook, ordering and value below was read from source this session); MEDIUM for the recommended shapes where CONTEXT leaves discretion; LOW items are listed in the Assumptions Log + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +#### Permissions and placement +- **D-01:** The screens are guarded by **two new permission codes**: + - `backend.manage_users` for Administrators (Winter's name is kept); + - `backend.manage_roles` for Roles. + + Roles change what every holder can do, so they get their own code. These are the first permissions cabana itself registers, through an internal pseudo-plugin (see Code Insights). — **Reversibility:** costly — the permission codes end up stored in `backend_user_roles.permissions` and `backend_users.permissions` JSON on every install that grants them. +- **D-02:** **Placement: Settings → System section**, as in Winter, through `pact.HasSettings` entries that link into the controllers. No top-level navigation item. +- **D-03:** **Superusers are hidden from non-superusers**, as in Winter: + - `ListExtendQuery` and `FormExtendQuery` filter `is_superuser` rows out; + - the `is_superuser` field and filter are removed for non-superusers; + - opening a superuser record by ID answers 404 or 403. +- **D-04:** **No default role grant.** Neither code is added to the `developer` or `publisher` system roles through code-declared `Roles`. Only superusers reach the screens until someone grants the codes, so existing installs don't widen silently on upgrade. + +#### Privilege rules (server-side, all enforced in the save transaction) +- **D-05:** **Only a superuser can create or edit a superuser, or set `is_superuser`** (roadmap SC-2). A non-superuser sending `is_superuser` is answered 403, even if the field is hidden. +- **D-06:** **The per-user permission override is editable**: a permissions tab on the Administrators form with `permissioneditor` mode **radio** (inherit / allow / deny), as in Winter. It is disabled when `is_superuser` is on. The column and its merge (`applyUserPermissions`, merged last) already exist. +- **D-07:** **A non-superuser can't grant more than they hold**, and this covers both permissions and role assignment. + - **Permission editors**, on both the per-user tab and the Roles screen: an option the editor doesn't hold is `PermissionOption.Locked`, and its stored value is kept on save (Winter's `getSaveValueSecure`). Changing a locked value is answered 403, the existing behaviour. + - **Role picker**: it offers only roles whose merged grants (stored JSON plus code-declared grants) are a subset of the editor's own permissions. Assigning any other role is refused on the server. + - **Roles screen**: a non-superuser can't add a permission they lack to any role. +- **D-08:** **Editing your own record is allowed, but privileged fields are read-only.** + - The admin can edit their profile fields on the Administrators screen. + - The server refuses, for the editor's own record, any change to `role_id`, `is_superuser`, `permissions` or `is_activated`, and refuses deleting yourself. + - This covers "an admin cannot raise their own role or permissions" (SC-2) in one place. There is no redirect to My Account. +- **D-09:** **The last active superuser is protected on every write path.** It can't be deactivated, demoted (`is_superuser` off), soft-deleted, or turned inactive by any route. The check covers: + - form update and delete; + - bulk actions, including bulk deactivate and bulk delete; + - record actions; + - any `admin:*` command path that could clear the flag. + + It runs inside the save transaction under a lock (for example a row lock on the active superuser set, or an advisory lock), so two concurrent demotions can't both pass. Today's `admin:*` commands don't demote, so their behaviour and output stay unchanged (SC-1). + +#### Lifecycle and passwords +- **D-10:** **Deactivate + soft delete + restore.** + - Activate and Deactivate are record and bulk actions that toggle `is_activated`. + - Delete is a soft delete through the existing `deleted_at`. + - A trashed filter lists deleted admins, and a Restore action brings them back. + - No hard delete, so FK references (tokens, `linked_by`, attachments) stay valid. +- **D-11:** **Passwords are typed in the form.** Password and confirmation fields are required on create and optional on update, and blank means unchanged. The value is bcrypt-hashed like `admin:create`. There is no "send reset link" action and no force-change-on-next-login in this phase. +- **D-12:** **Changes cut access immediately.** Deactivate, soft delete, and setting a password for another admin all bump `tokens_valid_after`, as `admin:reset-password` does. Existing JWTs and personal tokens then stop on the next request; `FindByID` already refuses inactive or deleted users. Restore and activate don't bump it. +- **D-13:** **No invite or welcome mail** on create in this phase. Winter's `send_invite` is deferred. + +#### Record-action seam +- **D-14:** **A generic cross-controller action extension.** It is a new optional `pact` capability through which a plugin contributes `AdminRecordAction`s to any admin controller by controller ID, including cabana's Administrators controller and other plugins' controllers. + - It may also cover toolbar and bulk actions if the shape allows it at no extra cost (planner's call). + - Contributions merge at boot. A duplicate action name on a controller, or an unknown controller ID, is a boot error. + - Each action keeps its own `Permissions`, `Applies` and `Confirm`. The existing row-locked transactional run path (`crud.go` `RecordAction`) runs it. + - The seam is additive and names neither MCP nor the user plugin. It is the seam 19.7 D-04/D-34 consumes. The researcher checks whether 19.6 D-15 ("Reset 2FA" contributed by sm-mfa-plugin to the user plugin's Users screen) can use it as is. + + — **Reversibility:** costly — this is a published `pact` capability that plugins (19.7's link action, sm-mfa-plugin) implement. +- **D-15:** **Contributed actions appear on the preview screen only**, through the existing `RecordActions.vue` on `PreviewView`. The Administrators controller therefore gets a preview block. There are no per-row actions on the list and no list payload changes. + +#### Roles screen +- **D-16:** **Fields and system roles follow Winter.** + - Fields: name, code (slugged from name), description, and a permissions tab using `permissioneditor` mode **checkbox**. + - System roles (`is_system`, or roles that code-declared `pact.Permission.Roles` grants attach to) have their code locked, and their code-declared grants are shown as locked. + - Name, description and any extra stored grants stay editable, within the D-07 ceiling. +- **D-17:** **A role that still has admins can't be deleted.** The delete returns a validation error that names how many admins hold the role. System roles can't be deleted. + +#### Quality gates +- **D-18:** Unit tests are the last plan (project rule). They cover every privilege rule in D-05 to D-09, including the concurrent last-superuser case, plus D-12 revocation and the D-14 seam (boot errors, permission filtering, run path). Docs: the cabana README and the affected `docs/` pages are updated in the same commits as the API, config and permission changes. If `admin/` changes, `check-admin-openapi.sh` and the committed `modules/boardwalk/dist` gate (`check-admin-dist.sh`) apply. + +### Claude's Discretion +- How the internal pseudo-plugin is shaped and named (id, for example `summercms.cabana` / `backend.*` controller IDs), as long as it reuses the existing `summercms.cabana` migration id and doesn't collide with the reserved first path segments. +- Exact Go identifiers for the D-14 capability and its types. +- List columns and filters (Winter's set: login, name, email, role, last login, activated; filters: role, superuser for superusers only, trashed), and form tab layout. +- The lock strategy for D-09. +- Whether toolbar and bulk actions join the D-14 seam in this phase. + +### Deferred Ideas (OUT OF SCOPE) +- Invite or welcome mail on admin create (Winter's `send_invite`): a later todo. +- A "send password reset link" record action and force-change-on-next-login for backend admins. +- Per-row action menus on list views. +- Backend user groups (Winter's groups tab). No table exists. +- Impersonate admin (Winter's `backend.impersonate_users`). + +#### Reviewed Todos (not folded) +- **Audit-log admin action runs with the admin id** (`.planning/todos/pending/admin-action-audit-log.md`): kept deferred to keep the phase short. Record and bulk runs already diary-log `admin_id`. +- **Verified link between a frontend user and a backend user** (`.planning/todos/pending/2026-10-17-frontend-backend-account-link.md`): already folded into 19.7 (D-01). This phase only provides its seam. +- The other keyword matches (scaffolding, plugin ports, i18n, benchmarks) were unrelated to this phase. + + +## Project Constraints (from CLAUDE.md) + +- Keep `go vet ./...` and `go test ./...` green at every commit; `go test -short ./...` is the Docker-free loop. Docker-backed tests skip under `-short` (a `TestMain` must call `flag.Parse()` and check `testing.Short()`). If the Docker socket is denied, run through `sudo -u golem`. +- Standard library first; a new dependency needs a research doc or decision note. **This phase needs none** (see Standard Stack). +- YAML through `goccy/go-yaml`, never `gopkg.in/yaml.v3`. +- A change to a module's exported API, config keys, CLI commands or dependencies updates that module's `README.md` and the affected `docs/` pages **in the same commit**. Every identifier in a README or docs page must exist (the docs checker enforces it: `summer docs:build --check`, `go test ./cmd/summer -run TestDocsTree`). +- Framework code never names a consuming application (neutral names only in READMEs, docs, examples, tests). +- Core plugins (user, blog, pages, payment) must not get breaking changes; the new `pact` capability is additive. +- If `admin/` changes: rebuild, commit `modules/boardwalk/dist`, pass `scripts/check-admin-dist.sh`; swag changes need `scripts/check-admin-openapi.sh`. The recommended design below needs **no `admin/` change** (see Open Questions 3 and 4 for the two places that would). +- Lean planning: few large plans; present the plan count with one-line scopes and wait for confirmation. **Unit tests are the last plan.** +- No co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by path (other sessions edit `STATE.md`/`state.json`; check for in-flight edits before touching cabana). No push/tag/deploy without go-ahead. State via `msd_run query state.*`. Post a g15office comment when the phase advances. + +## Summary + +Everything the phase needs already exists as a generic cabana seam, with three gaps that must be designed before planning. **What exists:** YAML-driven list/form/preview, `FormVirtualFields` (password, confirmation, and a virtual `is_superuser` switch), `PermissionEditorProvider` with per-request `Locked` options and the stored-value-kept merge (exactly Winter's `getSaveValueSecure`), `RelationLockProvider` (per-request locked related ids, enforced with 403 on save — the right tool for the D-07 role ceiling), `FormExtendQuery`/`ListExtendQuery` with the principal on `ctx` (D-03), row-locked record and bulk action run paths, `ListRowStates`, `FilterScope`/`FilterOptions`, settings link entries (`pact.SettingsItem.Controller`), and `tokens_valid_after` revocation. sm-user-plugin's Users screen is a complete working template for the controller (virtual password, `guardCredentials` re-read in the tx, bulk/record actions, row states). + +**The three gaps.** (1) `protectedFillKey` makes `role_id`, `permissions`, `is_superuser`, `is_activated` and `password` unfillable *and unprojected* by the generic CRUD — good for safety, but a virtual `is_superuser` switch would load as "off" on the edit form, and the SPA sends every field, so an untouched save would demote a superuser. cabana must project that value (an unexported in-package hook is enough, because the controller lives in cabana). (2) The save order is `FormBeforeUpdate` → `applyPermissionValues` → `checkRelationLocks` → `assignBelongsTo` → row write → `FormAfterUpdate`, so the new `role_id` is not visible in `FormBeforeUpdate`; the D-08 "no change to your own role" check must compare a snapshot taken in `FormBeforeUpdate` with the model in `FormAfterUpdate`. (3) Contributed record actions (D-14) are not offered or runnable today: `offeredRecordActions` and `recordActionOf` only see names in the owning controller's `config_form.yaml` `recordActions`. The seam must append contributed names to the compiled list at boot. + +**Activation caveat:** `Activate` returns `nil` when no plugin registers controllers, and several things depend on it — `examples/hello` boots without `admin.jwt.secret`, and `modules/surf/admin_prefix_test.go` asserts "no admin controllers" → not mounted. Add the internal pseudo-plugin **only when at least one real plugin has admin controllers**; then nothing else changes. + +**Primary recommendation:** Two plans. Plan 01: the `pact.HasAdminActionExtensions` seam, the internal `summercms.cabana` pseudo-plugin with `summercms.cabana.users` and `summercms.cabana.roles` controllers over `*cabana.BackendUser`/`*cabana.BackendUserRole`, the privilege guards (one shared `guardLastSuperuser` under `pg_advisory_xact_lock`), lang keys, and README/docs. Plan 02 (last): the unit and Postgres tests for D-05..D-09, D-12, D-14 and the concurrent case. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Administrators/Roles list, form, preview rendering | Browser (generic Vue SPA from `admin/`) | API (cabana schema endpoints) | The SPA renders any YAML-declared controller; no new Vue code is needed | +| Privilege rules D-05..D-09 | API / Backend (cabana controller hooks inside the save tx) | Database (advisory lock, counts in the tx) | Must hold for any client, including personal API tokens and hand-crafted bodies | +| Superuser hiding (D-03) | API (`ListExtendQuery`/`FormExtendQuery`, per-request schema filter) | — | The hidden field is display-only; the query scope is the control | +| Role ceiling and permission ceiling (D-07) | API (`RelationLockProvider`, `PermissionEditorProvider` `Locked`) | Browser (locked rows are display aids) | Framework already refuses locked changes with 403 | +| Last-superuser invariant (D-09) | Database (lock + count inside the write tx) | API (one guard called from every write path) | Only a serialized in-tx check survives concurrency | +| Session revocation (D-12) | Database (`tokens_valid_after`) | API (bouncer JWT check, `FindByID` refusal) | Existing mechanism; JWT guard compares `iat` with the cutoff per request | +| Cross-controller record actions (D-14) | API (boot-time merge in cabana, `pact` capability) | Browser (`RecordActions.vue` on preview, unchanged) | Contributions become ordinary record actions after the merge | +| Permission catalog and settings entries | API (cabana registry, pseudo-plugin) | — | Same path as any plugin's `HasPermissions`/`HasSettings` | + +## Standard Stack + +No new dependency. Everything is framework code plus the existing set. + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Go stdlib | go1.27.2 (local toolchain) | everything | Project rule: stdlib first [VERIFIED: `go version` this session] | +| gorm.io/gorm | v1.31.2 | models, tx, `clause.Locking` | Already the data layer [VERIFIED: summercms.go/go.mod:50] | +| PostgreSQL `pg_advisory_xact_lock(bigint)` | server feature (tests use `postgres:16-alpine`) | D-09 serialization | Built into Postgres; released at tx end [CITED: postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS] | +| bouncer.HashPassword / NeedsRehash | in-repo | bcrypt like `admin:create` | Same cost key `admin.password.bcrypt_cost` [VERIFIED: modules/cabana/commands.go:102, auth.go:481-490] | + +### Supporting (test only, already in go.mod) +| Library | Purpose | When to Use | +|---------|---------|-------------| +| testcontainers-go postgres module | Postgres for the privilege tests | Through the existing `adminGorm(t)` helper (skips under `-short`) [VERIFIED: modules/cabana/auth_test.go:314-360] | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| `pg_advisory_xact_lock` | `SELECT ... FROM backend_users WHERE is_superuser ... FOR UPDATE` | Deadlock-prone: every write path already holds its target row (`loadRecord`/`lockScoped` take `FOR UPDATE` first), so two demotions of different superusers would each hold one row and wait for the other; Postgres aborts one with a 500 instead of a clean refusal | +| `*cabana.BackendUser` as the record type | unexported wrapper model | A wrapper hides the type from contributors (19.7's link action could only use `RecordID`); `BackendUser` gains a few additive methods instead | +| `RelationLockProvider` for the role ceiling | `RelationExtendOptionsQuery` filtering | Filtering removes the target admin's *current* role from options, so `checkRelationScope` 422s every save of an admin who holds a role above the editor's ceiling, even a name change; locks keep the value, show it locked, and refuse only a change | + +**Installation:** none. + +## Package Legitimacy Audit + +No external packages are installed by this phase. **Packages removed:** none. **Packages flagged:** none. + +## Architecture Patterns + +### System Architecture Diagram + +``` +Boot (cabana.Activate) + plugins ──collectControllers──> [none?] ──yes──> return nil (unchanged: no admin, no secret) + │ some + ▼ + plugins + backendScreens{ID "summercms.cabana"} (internal pseudo-plugin: controllers users/roles, + │ permissions backend.manage_users/manage_roles, + │ 2 settings link entries, embedded AdminFS) + ▼ + compileRegistryWithTypes ──> compileActionExtensions (NEW: pact.HasAdminActionExtensions; + │ append contributed names to cc.Form.recordActions + cc.RecordActions; + │ unknown controller / no preview / duplicate name => boot error) + ▼ + compileContributions (permission catalog incl. backend.*, validates contributed action perms) + ▼ + backendScreens.reg = reg (controllers read the catalog + role grants per request) + +Request: PUT /backend/api/v1/summercms/cabana/users/{id} + guard (JWT or g15_ token) ─> protect(): RequiredPermissions backend.manage_users + ▼ + CRUDService.save tx: + loadRecord (FormExtendQuery: unscoped; non-superuser => is_superuser = false; FOR UPDATE) + Fill (protected keys never filled) ─> Validate (FormRules: password rules) + FormBeforeUpdate ── snapshot prior role_id; virtual is_superuser (D-05, D-08, D-09 guard); + │ password hash + tokens_valid_after (D-11, D-12); login/email uniqueness + applyPermissionValues ─> AdminSetPermissionValues (D-07 locked kept; D-08 self refused) + checkRelationLocks (role ceiling, D-07) ─> assignBelongsTo (role_id) + row write ─> FormAfterUpdate (D-08: self role_id changed vs snapshot => 403, rollback) + ▼ + projectFullRecord (+ unexported hook adds is_superuser to data) + +Record/bulk action: POST .../users/{id}/actions/deactivate or .../bulk/deactivate + tx: row lock ─> Applies ─> Run: self? 403 ; guardLastSuperuser (advisory lock + count others) ; + update is_activated=false, tokens_valid_after=now+1s +``` + +### Recommended Project Structure +``` +modules/cabana/ +├── backend_screens.go # pseudo-plugin (party.Plugin + HasAdminControllers, AdminAssets, +│ # HasPermissions, HasSettings), embed of backendscreens/ +├── backend_users_admin.go # summercms.cabana.users controller: hooks, actions, providers +├── backend_roles_admin.go # summercms.cabana.roles controller +├── backend_privileges.go # actsAsSuperuser, guardLastSuperuser, grant subset/ceiling helpers +├── action_extensions.go # compileActionExtensions (D-14 merge) +├── backendscreens/ # embedded AdminFS (go:embed) +│ ├── controllers/users/{config_list,config_form,config_filter}.yaml +│ ├── controllers/roles/{config_list,config_form}.yaml +│ └── models/{user,userrole}/{columns,fields}.yaml +modules/pact/capabilities.go # + AdminActionExtension, HasAdminActionExtensions +modules/phrasebook/backend/lang/{en,pl}/lang.yaml # + user.*, user.role.*, permissions/settings keys +``` + +### Pattern 1: Conditional internal pseudo-plugin in `Activate` +**What:** append the pseudo-plugin only when real controllers exist, and give it the compiled registry after compilation. +**Why:** `Activate` has `if len(items) == 0 { return nil, nil }` [VERIFIED: modules/cabana/http.go:79-81]; `examples/hello` has no admin controllers and no JWT secret, and `modules/surf/admin_prefix_test.go:91-99,194` asserts "no admin controllers" → not mounted [VERIFIED: read this session]. +**Rules it must satisfy:** controller IDs must start with the plugin ID (`id != p.ID() && !strings.HasPrefix(id, p.ID()+".")` is an error [VERIFIED: registry.go:33-36]); the route derives the ID from three segments `vendor.plugin.controller` [VERIFIED: http.go:1102]; the reserved first segments are `map[string]bool{"api": true, "assets": true, "login": true, "settings": true, "account": true}` [VERIFIED: registry.go:46] — `summercms` is free. A plugin with controllers needs `pact.AdminAssets` (`"cabana: plugin %s has admin controllers but no AdminFS"` [VERIFIED: registry.go:80-83]). `party.Plugin` is `ID() string; Requires() []string; Register(app *backpack.App) error; Boot(app *backpack.App) error` [VERIFIED: modules/party/registry.go:16-21]. +**Recommended IDs:** plugin `summercms.cabana`, controllers `summercms.cabana.users` and `summercms.cabana.roles` (SPA route `/backend/summercms/cabana/users`). The migration history id `summercms.cabana` stays in lagoon untouched (`migrator(gdb, "summercms.cabana", BackendAdminMigrations)` [VERIFIED: modules/lagoon/migrations.go:75]); no migration is needed. + +```go +// Sketch (identifiers are the planner's call). Source: modules/cabana/http.go:74-130 shape. +items, err := collectControllers(plugins) +if err != nil { return nil, err } +if len(items) == 0 { return nil, nil } // unchanged: no admin, no secret check +screens := &backendScreens{app: app} // party.Plugin, ID "summercms.cabana" +plugins = append(append([]party.Plugin(nil), plugins...), screens) +if items, err = collectControllers(plugins); err != nil { return nil, err } +// ... compileFieldTypes, compileRegistryWithTypes(items, fieldTypes) ... +// if err := compileActionExtensions(reg, plugins); err != nil { return nil, err } // before compileContributions +// ... compileContributions(reg, plugins), compileTokenScopes ... +screens.reg = reg // controllers read the catalog per request +``` + +### Pattern 2: The D-14 seam as a boot-time merge into existing record actions +**What:** a plugin-level capability (like `HasAdminControllers`, not a controller capability) returning contributions keyed by controller ID. The merge appends each contributed name to `cc.Form.recordActions` and the action to `cc.RecordActions`; after that `offeredRecordActions`, `recordActionOf`, `RecordAction` (row lock, Applies re-check, `TxFromContext`), the diary log line and `validatePermissions` all work unchanged. +**Why the merge is required:** both lookups iterate only declared names — `for _, name := range cc.Form.recordActions` [VERIFIED: crud.go:557] and `for _, entry := range cc.Form.recordActions { declared = declared || entry == name }` [VERIFIED: actions.go:242-247]. The compiled list is `recordActions []string` [VERIFIED: schema_types.go:159], unexported, so it is not in any payload. +**Boot checks to reuse:** an actions-only preview rule already exists: `recordActions needs a preview block (record actions are offered on the preview screen)` [VERIFIED: extension.go:91-93]; names must be identifiers, not `builtinToolbarActions = map[string]bool{"create": true, "delete": true}` [VERIFIED: extension.go:47], unique, with `Run` and a non-empty `Label` [VERIFIED: extension.go:94-102, 401-427]. Add: unknown controller ID → error; target without a form or preview block → error; duplicate against the owner's names and other contributions → error. `compileContributions` then validates each action's `Permissions` against the catalog because it iterates `controller.RecordActions` [VERIFIED: registry.go:342-346] — so the merge must run **before** `compileContributions`. +**Shape (additive, discretion on names):** + +```go +// modules/pact/capabilities.go — uses the existing AdminRecordAction type verbatim: +// type AdminRecordAction struct { Name, Label, Confirm string; Permissions []string; Danger bool; +// Applies func(ctx context.Context, record any) (bool, error); Run func(ctx context.Context, +// in AdminRecordActionInput) (AdminRecordActionResult, error) } [VERIFIED: capabilities.go:343-354] + +// AdminActionExtension adds actions to an admin controller the plugin may not own. +type AdminActionExtension struct { + Controller string // controller ID, vendor.plugin.controller + RecordActions []AdminRecordAction // offered on the controller's preview screen after its own +} + +// HasAdminActionExtensions is implemented by plugins (not controllers). +type HasAdminActionExtensions interface { + AdminActionExtensions() []AdminActionExtension +} +``` +A struct (not a bare slice) lets `BulkActions` be added later as a field without breaking implementers. **Recommendation: record actions only in this phase.** Bulk contributions would also need entries in the list schema's `BulkActions` view (label/confirm built from YAML today), which is not free. +**Protection is inherited:** `protect()` checks the target controller's `RequiredPermissions` before any action, then `allowAction` checks the action's own `Permissions` [VERIFIED: http.go:1101-1110, actions.go:256-262]. A contributed action on the Administrators screen therefore needs `backend.manage_users` **and** its own code. + +**19.6 D-15 can use it as is**, with two conditions: the target `golem15.user.users` already has a preview block (`preview:` + `recordActions: [activate, unban, unsuspend]` [VERIFIED: fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml:14-17]), and the contributed name must not collide with a name the user plugin itself registers. 19.6-01-PLAN already plans an in-plugin `reset_two_factor` slot (the user plugin registers the action, mfa publishes `classes.TwoFactorAdmin`) [VERIFIED: 19.6-01-PLAN.md:49,270]; if that ships, mfa must not also contribute `reset_two_factor`. Nothing in 19.6 has to change. + +**19.7 caveat:** `AdminRecordActionInput` carries only `RecordID uint64` and `Record any` [VERIFIED: capabilities.go:360-363] — no payload. "Remove link" fits; "set a link for editor onboarding" (19.7 D-07) needs a chosen frontend user, which a record action cannot carry. See Open Question 5. + +### Pattern 3: Administrators controller over `*cabana.BackendUser` +**Model requirements:** the generic CRUD needs `pact.AdminRecordSource`, a model implementing `lagoon.HasFillable` and `Rules() map[string]string` (`newWritableModel` [VERIFIED: crud.go:907-927]); `scope` filters need `pact.FilterScope` **on the model** (`filterProvider` asserts `src.NewRecord().(pact.FilterScope)` [VERIFIED: filter_schema.go:311-322]); `FilterOptions` may be on the controller [VERIFIED: filter_schema.go:301-309]. Recommend adding `Fillable()`, `Rules()`, `FilterScopes()`, `FilterScope()` to `BackendUser` (additive; `BackendUser` is used by sm-journal-plugin as a relation target and importer type, none of which consults these methods) and the same `Fillable`/`Rules` to `BackendUserRole`. + +**Protected keys** (never filled, never projected, never bound): `"id", "created_at", "updated_at", "deleted_at", "owner_id", "user_id", "collection_id", "organisation_id", "organization_id", "scope_id", "role_id", "permissions", "is_superuser", "is_system", "is_activated", "password"` [VERIFIED: crud.go:1407-1417]. Consequences: +- `role` is a `type: relation` belongsTo with `FieldRelationContract{Field: "role", Kind: "belongsTo", NewRelated: ..., ForeignKey: "role_id", LabelColumn: "name", WritableForeignKey: true}` — `WritableForeignKey` is the documented opt-in for a protected foreign key [VERIFIED: relation_field.go:41-43, 247], the same as sm-user-plugin's `organisation` [VERIFIED: sm-grzybyfunkcjonalne-app/plugins/golem15/user/controllers/users_admin_controller.go:348-357]. +- `password`, `password_confirmation` and `is_superuser` are listed in `FormVirtualFields`; allowed virtual types are `"password", "text", "textarea", "number", "checkbox", "switch", "dropdown"` [VERIFIED: extension.go:158-161]. A `type: password` field must be listed (`checkVirtualFields` [VERIFIED: extension.go:170-175]). Virtual values reach hooks through `cabana.VirtualFieldsFromContext`, only for keys present in the body ("a missing key means 'not submitted'") [VERIFIED: tx_context.go:46-70, crud.go:1269-1288] — so D-05's "sending `is_superuser` → 403" is detectable even when the field is hidden. +- `permissions` uses `type: permissioneditor` (`mode: radio` for users, `mode: checkbox` for roles); it is lifted separately and never filled [VERIFIED: field_permission.go:177-211]. +- `is_activated` only changes through record/bulk actions (D-10). + +**Save order (the timing every guard depends on)** [VERIFIED: crud.go:771-848]: `lagoon.Fill` → `Validate` (merged `FormRules`) → `formBeforeUpdate` (line 800) → `applyPermissionValues` (809) → `checkRelationScope` → `checkRelationLocks` (819) → `assignBelongsTo` (822) → `tx.Unscoped().Save(target)` (829) → `formAfterUpdate` (848). In `FormBeforeUpdate` the model still holds the **stored** `RoleID`, `IsSuperuser`, `IsActivated` and `Permissions` (protected keys are never filled); the new `role_id` appears only after `assignBelongsTo`. + +**Hook plan (the guards):** + +| Where | Check | Answer | +|-------|-------|--------| +| `ListExtendQuery`, `FormExtendQuery` | `db.Unscoped()` (trashed admins listable and restorable, as sm-user-plugin does [VERIFIED: users_admin_controller.go:84-97]); non-superuser → `Where("backend_users.is_superuser = ?", false)` (qualified: list search uses correlated subqueries next to unqualified scope predicates [VERIFIED: query.go:485-490]) | hidden superuser by id = 404 through `loadRecord`'s not-found [VERIFIED: crud.go:946-953] | +| `FormBeforeCreate` | virtual `is_superuser` present and not `actsAsSuperuser` → 403; set `IsSuperuser`; set `IsActivated = true`, `ActivatedAt = now` (as `admin:create` does [VERIFIED: commands.go:106-115]); hash password; login/email checks | `*cabana.ForbiddenError` (403) / `*cabana.ValidationError` (422) | +| `FormBeforeUpdate` | snapshot prior `RoleID`; `is_superuser` present: non-superuser → 403, self and changed → 403, demotion of an active superuser → `guardLastSuperuser`; password: hash, bump `TokensValidAfter` (self: see OQ 2); login/email uniqueness | 403 / 422 | +| `AdminSetPermissionValues` (users) | self and `next` ≠ stored → 403; store JSON | 403 | +| `AdminRelationLocks(ctx, "role")` | non-superuser: lock every role whose merged grants ⊄ editor's | framework 403 on change [VERIFIED: relation_field.go:55-67] | +| `FormAfterUpdate` | self and `RoleID` ≠ snapshot → 403 (rolls the tx back) | 403 | +| `FormBeforeDelete` | self → 403; active superuser → `guardLastSuperuser` | 403 | +| `FormAfterDelete` | bump `tokens_valid_after` on the (now trashed) row through `tx.Unscoped()` | — | +| record + bulk `activate` / `deactivate` / `restore` | deactivate: self → 403, active superuser → `guardLastSuperuser`, bump; activate: set `activated_at`; restore: `deleted_at = NULL` | 403 | + +`deleteRecord` calls `FormBeforeDelete` once per row inside the bulk delete transaction [VERIFIED: crud.go:366-373, 1127-1143], so one refused row rolls back the whole selection, and the per-row "count the others" check sees earlier rows' writes in the same tx. + +**The `is_superuser` projection gap.** Protected keys are skipped in `projectRecord` [VERIFIED: crud.go:1354-1378] and virtual fields are "never bound, filled or projected" [VERIFIED: contracts.go:133-135]. The SPA sends every non-readOnly field on save (`editablePayload` [VERIFIED: admin/src/components/form/formState.ts:53-77]). An unprojected switch therefore shows "off" for a superuser and is sent as `false` when the form is saved → silent demotion. Fix: an **unexported** in-package interface that `projectFullRecord` consults (only cabana's own controllers can implement an unexported method), adding `is_superuser` to `data`. The same mechanism (another unexported interface consulted in `formSchema`/`listSchema`) removes the `is_superuser` field and filter for non-superusers (D-03); today `formSchema` only drops widget fields per request [VERIFIED: http.go:767-785]. No `pact` surface, no payload change for other controllers. + +**Prior-state snapshot.** Use an unexported field on `BackendUser` (for example `adminPrior *uint` plus a set flag). GORM parses only exported fields (`if fieldStruct := modelType.Field(i); ast.IsExported(fieldStruct.Name)` [VERIFIED: gorm.io/gorm@v1.31.2/schema/schema.go:220-221]) and cabana's `flattenModelFields` skips `f.PkgPath != ""` [VERIFIED: model_fields.go:54-56]. The same pointer is passed to `formBeforeUpdate` and `formAfterUpdate` [VERIFIED: crud.go:800, 848]. + +### Pattern 4: `guardLastSuperuser` under an advisory transaction lock +**What:** one function every reducing path calls before it writes: take `pg_advisory_xact_lock()`, then count `is_superuser AND is_activated AND deleted_at IS NULL AND id <> target` through the write tx (`cabana.TxFromContext`); refuse when the count is 0 and the target is currently an active superuser. +**Why it is correct:** every path takes its target row lock first (`loadRecord`/`lockScoped`, `FOR UPDATE` [VERIFIED: crud.go:946, 1058-1060]) and then the advisory lock; nothing takes a row lock after the advisory lock (the count is a plain `SELECT`), so there is no wait cycle. Under READ COMMITTED, the second transaction's count runs after the first commits (it waited on the advisory lock), so it sees the first demotion. Re-entrant within one tx, so a bulk action calling it per row is fine. +**Scope:** only paths that reduce the set (demote, deactivate, delete). Create/restore/activate raise it. `admin:create`/`admin:reset-password` never clear the flag [VERIFIED: commands.go:71-168], so they stay unchanged (SC-1); 19.8 already reshaped those commands' output (commit `9d06a78`), so do not touch `commands.go`. + +### Pattern 5: Ceilings (D-07) +- **Effective grants of a role** = `parseGrants(role.Permissions)` ∪ `reg.rolePermissions(role.Code)` — the same merge as `FindByID` [VERIFIED: auth.go:39-67, registry.go:449-460]. +- **Editor's grants** = `principal.PermissionGrants` (already merged per request by `FindByID`). +- **Subset test:** for each role code `c` without a wildcard, `granted(editor, c)` [VERIFIED: contracts.go:214-248]. **Do not** pass a wildcard role grant (`acme.*`) to `granted`/`Allows` — a required code ending in `*` means "any matching grant", not "all" [VERIFIED: contracts.go:216-226]. Treat a wildcard role grant as covered only when the editor holds the same or a broader wildcard (conservative; see A3). +- **Options:** `AdminPermissionOptions` returns the catalog (`reg.permissions`, a map — add an ordered slice in `compilePermissionCatalog` for stable display) with `Locked: !actsAsSuperuser(p) && !granted(p.PermissionGrants, code)`. The framework then keeps stored non-offered codes, refuses a changed locked value with `ForbiddenError` on the field (`permissionLockedKey = "backend::lang.permissioneditor.locked"` [VERIFIED: field_permission.go:24, 303-307]). +- `actsAsSuperuser(p)` = `p != nil && p.IsSuperuser && (p.Scope == nil || p.Scope.Full)` — a superuser's scoped personal token never grants superuser-only powers. `Scope` is `*ScopeLimit{Full bool; Scopes [][]string}` [VERIFIED: modules/bouncer/context.go:16-35]. + +### Pattern 6: Roles controller (D-16, D-17) +- Fields: `name`, `code` (`preset: {type: slug, field: name}` — `preset` is a supported field key [VERIFIED: form_schema.go:47]), `description` (`textarea`), `permissions` (`permissioneditor`, `mode: checkbox`). Roles have no `deleted_at` (`BackendUserRole` has no `DeletedAt` [VERIFIED: contracts.go:17-26]) → delete is a hard delete. +- `code` is fillable (not protected), so `Fill` has already written it before `FormBeforeUpdate`: re-read the stored row through a fresh session on the tx (sm-user-plugin's `guardCredentials` pattern [VERIFIED: users_admin_controller.go:458-470]) and refuse a code change on a system role (`is_system` or `reg.roleGrants[code]` non-empty). +- `FormBeforeDelete`: system role → refuse; count admins with `role_id` **unscoped** (soft-deleted admins still reference the row through `role_id INTEGER REFERENCES backend_user_roles(id)` with no `ON DELETE` [VERIFIED: backend_admin_migrations.go:50]) → `ValidationError` naming the count. +- Rules: `name` required + `unique:backend_user_roles` (DB has `name TEXT NOT NULL UNIQUE` [VERIFIED: backend_admin_migrations.go:29]); `code` unique too (DB has only an index, `code TEXT,` [VERIFIED: :30]; `roleIDByCode` fails on an ambiguous code [VERIFIED: commands.go:192-207]). + +### Anti-Patterns to Avoid +- **Hand-written routes for the screens.** Every route exists generically; new routes would need swag annotations, OpenAPI regeneration and SPA work. +- **Using `RelationExtendOptionsQuery` for the role ceiling** (see Alternatives). +- **Checking `role_id` in `FormBeforeUpdate`.** It still holds the stored value. +- **`Preload("Role")` in `FormExtendQuery`.** GORM saves a non-zero belongs-to association and resets the foreign key from it (`setupReferences` → `ref.ForeignKey.Set(..., pv)`; a zero association is skipped) [VERIFIED: gorm@v1.31.2/callbacks/associations.go:24-29, 92-93], so a preloaded old `Role` would overwrite the new `role_id` on `Save`. +- **Trusting lagoon's `unique` rule for login/email.** It adds `deleted_at IS NULL` and is case-sensitive [VERIFIED: modules/lagoon/validate.go:359-381]; the DB has `login TEXT NOT NULL UNIQUE`, `email TEXT NOT NULL UNIQUE` [VERIFIED: backend_admin_migrations.go:41-42] plus `lower(email)` unique, all including soft-deleted rows. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Permission ceiling on save | own diffing of submitted permissions | `PermissionEditorProvider` + `Locked` | `applyPermissionValues` already keeps unoffered codes, rejects unknown codes and 403s locked changes | +| Role ceiling | own check on `role_id` | `RelationLockProvider` | Enforced before any row write, shows locked options | +| Password hashing / rehash | custom bcrypt | `bouncer.HashPassword(adminBcryptCost(app), pw)` | Same cost and 72-byte limit as login and `admin:create` | +| Session revocation | blacklist entries | `tokens_valid_after` | JWT guard compares `iat` per request [VERIFIED: bouncer/jwt.go:160-173] | +| Record/bulk action plumbing | new endpoints | `HasAdminRecordActions` / `HasAdminBulkActions` | Row locks, Applies re-check, CSRF, diary log already done | +| Settings placement | navigation item | `pact.SettingsItem{Controller: ...}` link entries | Listed only when the principal can open the controller [VERIFIED: navigation.go:87-93] | + +**Key insight:** the only new mechanisms are the D-14 merge, two unexported projection/schema hooks, and the last-superuser lock. Everything else is configuration of existing seams. + +## Common Pitfalls + +### Pitfall 1: Silent superuser demotion from an unprojected switch +**What goes wrong:** a superuser edits another superuser's name; the form sends `is_superuser: false`; the target is demoted. +**Why:** protected and virtual fields are not projected; the SPA sends every field. +**How to avoid:** project `is_superuser` through the unexported hook; test that GET returns it and that saving an untouched form keeps it. + +### Pitfall 2: Self role change slips through `FormBeforeUpdate` +**Why:** `assignBelongsTo` runs after the hook. **Avoid:** snapshot + `FormAfterUpdate` check, with a test that sends a new `role` for the editor's own record and asserts 403 and an unchanged row. + +### Pitfall 3: "No controllers means no admin" broken +**What goes wrong:** `examples/hello` and every admin-less app fail boot with `cabana: admin.jwt.secret is empty`; `surf` prefix tests fail. **Avoid:** conditional pseudo-plugin; run `cd examples/hello && go test ./...` and `go test ./modules/surf`. + +### Pitfall 4: Soft-deleted rows still own their login and email +**What goes wrong:** creating `editor@example.com` after deleting an admin with that email is a 500 (unique violation). **Avoid:** an unscoped, case-insensitive check mirroring `admin:create` (`login IN (?, ?) OR lower(email) IN (?, ?)`, excluding the record's own id [VERIFIED: commands.go:96]); answer 422 on the field and mention that a deleted administrator holds the value (restore it). + +### Pitfall 5: Role delete hits the FK +**Why:** soft-deleted admins still reference the role. **Avoid:** unscoped count in `FormBeforeDelete`. + +### Pitfall 6: Deadlock instead of a refusal under concurrency +**Avoid:** advisory lock, not `FOR UPDATE` on the superuser set (Pattern 4). + +### Pitfall 7: Personal API tokens survive a password reset and revive on restore +**What:** `tokens_valid_after` cuts JWTs only; personal tokens are cut by `FindByID` refusing inactive/deleted users (`token_guard.go:100-104`) and are explicitly untouched by `admin:reset-password` ("Personal API tokens are not touched" [VERIFIED: commands.go:47-50]). So D-12's "personal tokens then stop" holds for deactivate and delete, not for a password set; and restore/activate (which don't bump) bring the admin's personal tokens back. See Open Question 6. + +### Pitfall 8: Existing tests and consumers see two new settings entries for superusers +**What:** a superuser's `/settings` gains "Administrators" and "Roles". Non-superuser lists are unchanged (D-04: no default role grant), so e.g. the fonoteka developer-settings assertion (`reflect.DeepEqual(settings, []string{"fonoteka"})`) still holds [VERIFIED: fonoteka.go/plugins/golem15/fonoteka/admin_metadata_test.go:156-158]. Grep sibling repos for superuser-settings assertions before the framework tag. + +### Pitfall 9: bcrypt input limit +`HashPassword` refuses input over 72 bytes; My Account checks `len(password) > passwordMaxBytes` (72) itself [VERIFIED: account.go:46-47, 335-336]. Add the same byte check for the admin form's password, with `min:4` as in Winter (`'password' => 'required:create|min:4|confirmed'` [VERIFIED: examples/golem15-wintercms-starter/modules/backend/models/User.php:30]). + +### Pitfall 10: Missing lang keys +`backend::lang.user.*` does not exist yet: the backend namespace has only `auth, nav, page, list, form, relation, settings, extension, datepicker, money, taglist, secret, fileupload, messages, permissioneditor, api_tokens, myaccount, richeditor, repeater, reorder` [VERIFIED: modules/phrasebook/backend/lang/en/lang.yaml top-level keys]. `validateMessageKeys` fails boot on a missing `messages.*` phrase key [VERIFIED: messages.go:296-315]. Port the strings from Winter's `modules/backend/lang/en/lang.php` and `pl/lang.php` (`'user' => [ 'name' => 'Administrator', 'menu_label' => 'Administrators', ...` [VERIFIED: examples/golem15-wintercms-starter/modules/backend/lang/en/lang.php:133-137]). + +## Code Examples + +### The last-superuser guard (skeleton) +```go +// Source: pattern from this research; TxFromContext from modules/cabana/tx_context.go:27-33. +// The lock key is any fixed bigint constant (planner picks it). +func guardLastSuperuser(ctx context.Context, target *BackendUser) error { + if !target.IsSuperuser || !target.IsActivated || target.DeletedAt.Valid { + return nil // the write does not shrink the active superuser set + } + tx, ok := TxFromContext(ctx) + if !ok { + return errors.New("cabana: 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 id <> ?", target.ID). + Count(&others).Error; err != nil { // default scope adds deleted_at IS NULL + return err + } + if others == 0 { + return &ForbiddenError{Message: "backend::lang.user.last_superuser"} // new phrase key + } + return nil +} +``` + +### Revocation like `admin:reset-password` +```go +// Source: modules/cabana/commands.go:157-162 (verbatim values): +// cutoff := time.Now().UTC().Add(time.Second) +// ... Updates(map[string]any{"password": hash, "tokens_valid_after": cutoff}) +cutoff := time.Now().UTC().Add(time.Second) +user.TokensValidAfter = &cutoff // in FormBeforeUpdate; Save writes it +``` + +### A contribution from another plugin (what 19.7 writes) +```go +// Neutral example; Permissions must be a code some activated plugin registers. +func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension { + return []pact.AdminActionExtension{{ + Controller: "summercms.cabana.users", + RecordActions: []pact.AdminRecordAction{{ + Name: "unlink_profile", Label: "acme.profiles::lang.unlink", Danger: true, + Permissions: []string{"acme.profiles.manage_links"}, + Applies: func(ctx context.Context, record any) (bool, error) { /* pure read */ return true, nil }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) // the action's row-locked transaction + _ = tx; _ = ok; _ = in.RecordID // backend_users.id + return pact.AdminRecordActionResult{Message: "acme.profiles::lang.unlinked"}, nil + }, + }}, + }} +} +``` + +### Settings link entries (D-02) +```go +// pact.SettingsItem fields: Code, Label, Description, Category, Icon, Model, Order, +// Keywords, Permissions, Controller, Form, NewModel [VERIFIED: modules/pact/capabilities.go:570-587]. +// A link entry declares Controller and no Model/Form/NewModel [VERIFIED: settings.go:57-65]. +pact.SettingsItem{Code: "administrators", Controller: "summercms.cabana.users", + Permissions: []string{"backend.manage_users"}, Label: "backend::lang.user.menu_label", + Category: ""} +``` +`Code` must be an identifier and unique across all settings [VERIFIED: registry.go:287-292] — check that no installed plugin already uses `administrators`/`roles` (none found in the sibling repos grepped this session). + +### Filters available to the users list +Supported filter types are only `switch` (boolean `column`), `daterange` (time `column`) and `scope` (`scope`, `modelClass`, `nameFrom` required; `FilterScope` on the model and `FilterOptions` to serve choices); Winter's `conditions` key is refused [VERIFIED: filter_schema.go:112-114, 170-190, 218-248]. So: `is_superuser` → `switch`; `role` → `scope`; trashed → `scope` with two options (only deleted / without deleted); last login → `daterange`. + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Admins only through `admin:create` / `admin:reset-password` | SPA screens + same commands | this phase | docs `users-and-permissions.md` "Managing administrators" and the My Account paragraph ("role, superuser flag, permissions and activation stay with the operator commands below" [VERIFIED: docs/backend/users-and-permissions.md:138]) must change | +| Winter: no last-superuser rule; self-edit redirects to My Account | D-08/D-09 server rules | this phase | Deliberate tightening (CONTEXT specifics) | +| Winter: system role permissions entirely disabled and code-defined; developer inherits orphaned permissions [VERIFIED: modules/backend/models/UserRole.php:38-63, 91-99] | Stored extras editable on top of code grants (D-16); cabana merges stored + code grants and has no orphan rule [VERIFIED: auth.go:54-66] | — | No behaviour change to `FindByID` in this phase | + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | Adding `Fillable`/`Rules`/`FilterScopes`/`FilterScope` methods and an unexported field to the exported `cabana.BackendUser` is non-breaking for consumers (none found using unkeyed literals or `==` on it) | Pattern 3 | A consumer that compares `BackendUser` values or embeds it in a model with its own `Rules` could change behaviour; mitigated by grepping sibling repos before tagging | +| A2 | `pg_advisory_xact_lock` ordering after row locks is deadlock-free for every path listed | Pattern 4 | A future path that takes a row lock after the advisory lock could deadlock; the concurrency test catches regressions | +| A3 | Conservative wildcard rule for the role ceiling (a role's `x.*` grant is covered only by an equal or broader editor wildcard) is acceptable | Pattern 5 | Could lock roles a non-superuser should be able to assign; superusers unaffected | +| A4 | Recommended IDs `summercms.cabana.users` / `summercms.cabana.roles` (URLs `/backend/summercms/cabana/...`) are acceptable instead of Winter's `backend/users` | Pattern 1 | Purely cosmetic; IDs become stored in nothing except settings codes and links | +| A5 | Record actions only (no bulk/toolbar) in the D-14 seam this phase | Pattern 2 | 19.7 may want a bulk "unlink"; the struct shape keeps it additive | +| A6 | The SPA renders the new controllers with no `admin/` change | Summary | If a gap shows up in UAT (for example the users-count column), it means an admin/ change and a dist rebuild | + +## Open Questions + +1. **Can a non-superuser manage an admin who outranks them?** (security, not in CONTEXT) + - What we know: D-07 stops granting more than you hold, but a non-superuser with `backend.manage_users` can still set the password of, deactivate, or delete a non-superuser admin whose role grants more than their own. Setting the password is an account takeover, which escalates privilege. sm-user-plugin guards its privileged groups the same way (`guardPrivilegedMember` [VERIFIED: users_admin_controller.go:192-209]). + - Recommendation: add an "outranks" refusal. When the target's effective grants are not a subset of the editor's, a non-superuser gets a 403 on update, delete and actions, using the Pattern 5 helper. This needs user confirmation. +2. **Changing your own password on the Administrators screen.** D-08 doesn't list `password`. My Account requires the current password and mints a new session; this screen would do neither. Recommendation: refuse a password change on your own record here and point to My Account. This needs confirmation. +3. **D-06 "disabled when `is_superuser` is on".** cabana's fields.yaml has no `trigger` key: the accepted keys are listed in `formFieldKeys` and include no `trigger` [VERIFIED: form_schema.go:38-50]. Options: + - (a) server-side only: superusers bypass `Allows`, so their per-user permissions are inert, and a comment on the field says so; + - (b) add `trigger` support, which changes `admin/` (dist rebuild, possibly the OpenAPI schema). + - Recommendation: (a), recorded as a deviation. +4. **D-16 "code-declared grants shown as locked" per role.** `PermissionOption.Locked` is per request, not per record: `AdminPermissionOptions(ctx, field)` gets no record, and the form schema is fetched without an id [VERIFIED: field_permission.go:61-65, http.go:753-759]. Options: + - (a) show the merged values. A save that drops a code-declared grant is refused with a 403 using the locked phrase, or the code-declared grants are stripped from the stored JSON, since they are merged at runtime anyway; + - (b) an optional record-level locks provider whose codes travel in record meta, which changes `admin/` and the OpenAPI schema. + - Recommendation: (a). This needs confirmation, because it softens "shown as locked". +5. **19.7 "set a link" needs input.** Record actions carry no payload [VERIFIED: capabilities.go:360-363]. + - Option: `AdminRecordActionInput` could later gain an optional `Payload json.RawMessage`, mirroring `AdminActionInput.Payload` [VERIFIED: capabilities.go:245-251]. That is additive, but it needs SPA input UI. + - Recommendation: keep it out of 19.6.1, design it in 19.7, and note it in the seam docs. +6. **Personal API tokens on password set and on restore** (Pitfall 7). + - Options: revoke the target's personal tokens on deactivate and delete, or accept today's semantics and document them. + - Recommendation: document them, matching `admin:reset-password`. Revoking tokens would widen the phase. +7. **Users count on the Roles list.** Winter shows `users_count`. cabana columns have no count/`valueFrom` key: `columnDocument` holds `Label`, `Searchable`, `Sortable`, `Type`, `Relation`, `Select`, `Invisible`, `Currency`, `CurrencyFrom` [VERIFIED: list_schema.go:62-73]. Recommendation: omit it in this phase. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Go toolchain | build, tests | ✓ | go1.27.2 | — | +| Docker daemon | Postgres testcontainers (privilege, concurrency tests) | ✓ | Server 29.9.1 | `sudo -u golem` if the socket is denied (CLAUDE.md) | +| Node / npm | only if `admin/` changes | ✓ | v22.23.3 / 12.2.1 | not needed by the recommended design | + +**Missing dependencies with no fallback:** none. + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Go `testing`; testcontainers-go Postgres (`postgres:16-alpine`) through `adminGorm(t)`, which skips under `-short` [VERIFIED: auth_test.go:321-325] | +| Config file | none (package helpers `adminGorm`, `adminHandler`, `insertAdmin` in `modules/cabana/auth_test.go`, package `cabana_test`) | +| Quick run command | `go test -short ./modules/cabana -run 'TestActionExtension\|TestBackendScreens\|TestRoleCeiling'` | +| Full suite command | `go vet ./... && go test ./... && (cd examples/hello && go test ./...) && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check` | + +### Phase Requirements → Test Map +(No requirement IDs are mapped; rows key on decisions.) + +| Decision | Behavior | Type | Automated Command | File Exists? | +|----------|----------|------|-------------------|-------------| +| Activation | no real controllers → `Activate` nil, hello boots without secret; with controllers → pseudo-plugin controllers, permissions, 2 settings links | unit | `go test -short ./modules/cabana -run TestBackendScreensActivation`; `go test ./modules/surf -run TestAdminPrefix`; `cd examples/hello && go test ./...` | ❌ Wave 0 (surf/hello exist) | +| D-14 | boot errors: unknown controller, no preview, duplicate vs owner, duplicate between contributors, reserved name, nil Run, empty label, unknown permission | unit | `go test -short ./modules/cabana -run TestActionExtensionBoot` | ❌ | +| D-14 | offered only with permission + Applies, after owner's actions; run path row-locks, re-checks Applies (409), runs in tx, requires target controller permission | Postgres | `go test ./modules/cabana -run TestActionExtensionRun` | ❌ | +| D-03 | non-superuser: list excludes superusers, GET superuser id 404, schema lacks `is_superuser` field and filter; superuser sees them | Postgres + unit | `go test ./modules/cabana -run TestAdministratorsSuperuserHidden` | ❌ | +| D-05 | non-superuser sending `is_superuser` (true or false) on create/update → 403, row unchanged; superuser can grant | Postgres | `go test ./modules/cabana -run TestAdministratorsSuperuserFlag` | ❌ | +| Pitfall 1 | GET projects `is_superuser`; saving an untouched superuser form keeps the flag | Postgres | `-run TestAdministratorsSuperuserProjected` | ❌ | +| D-06/D-07 | per-user radio: locked codes for non-held perms, changed locked → 403, stored kept; Roles checkbox same | Postgres | `-run TestAdministratorsPermissionCeiling\|TestRolesPermissionCeiling` | ❌ | +| D-07 | role options locked above ceiling; assigning locked role → 403; unchanged locked current role saves fine | Postgres | `-run TestAdministratorsRoleCeiling` | ❌ | +| D-07 | subset helper incl. wildcard cases | unit (package cabana) | `go test -short ./modules/cabana -run TestRoleCeiling` | ❌ | +| D-08 | self: change role (FormAfterUpdate path) / is_superuser / permissions / deactivate / delete → 403; profile fields save | Postgres | `-run TestAdministratorsSelfEdit` | ❌ | +| D-09 | last active superuser: demote, deactivate (record + bulk), delete (form + bulk) → refused, rollback; with two superusers bulk-deleting both → refused | Postgres | `-run TestAdministratorsLastSuperuser` | ❌ | +| D-09 | concurrent: two goroutines demote two different superusers; a third tx holds the advisory lock until `pg_locks` shows 2 waiters, then releases; exactly one succeeds, ≥1 active superuser remains, no deadlock error | Postgres | `-run TestAdministratorsLastSuperuserConcurrent` | ❌ | +| D-10 | activate/deactivate/restore record + bulk; soft delete; trashed filter; row state `deleted` | Postgres | `-run TestAdministratorsLifecycle` | ❌ | +| D-11 | create needs password + confirmation; update blank = unchanged; bcrypt verifies; >72 bytes 422; duplicate login/email incl. deleted and case variants 422 | Postgres | `-run TestAdministratorsPasswordAndIdentity` | ❌ | +| D-12 | after deactivate / delete / password set for another admin, the target's old JWT → 401 next request; restore/activate don't bump | Postgres (HTTP) | `-run TestAdministratorsRevocation` | ❌ | +| D-16/D-17 | system role code change refused; delete with holders (incl. soft-deleted) → 422 naming count; system role delete refused | Postgres | `-run TestRolesLifecycle` | ❌ | +| SC-1 | `admin:*` commands and `/auth/me`, `/account` payloads unchanged | existing | `go test ./modules/cabana -run 'TestAdmin\|TestAccount\|TestMe'` | ✅ | + +### Sampling Rate +- **Per task commit:** `go vet ./modules/cabana ./modules/pact && go test -short ./modules/cabana ./modules/pact ./modules/surf` +- **Per wave merge:** `go test ./modules/cabana ./modules/surf` (Docker) + `cd examples/hello && go test ./...` +- **Phase gate:** full suite green; optionally `scripts/check-phase19.6.1.sh` wrapping the above, as earlier phases do (`scripts/check-phase19.5.sh`). + +### Wave 0 Gaps +- [ ] `modules/cabana/backend_screens_test.go`: activation and schema (`-short`) +- [ ] `modules/cabana/action_extensions_test.go`: D-14 boot and run +- [ ] `modules/cabana/backend_privileges_internal_test.go`: subset/ceiling helpers (`package cabana`) +- [ ] `modules/cabana/administrators_postgres_test.go`: D-03..D-12 HTTP-level, through `adminHandler` and `insertAdmin`. Each test seeds its own uniquely named admins and cleans them up, because the container is shared by the package. +- [ ] `modules/cabana/roles_postgres_test.go`: D-16/D-17 +- [ ] `insertAdmin` has no superuser flag; add a helper that sets `is_superuser`. + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes (password set) | bcrypt via `bouncer.HashPassword`, min 4 / max 72 bytes, confirmation | +| V3 Session Management | yes | `tokens_valid_after` cutoff, `FindByID` refusal of inactive/deleted | +| V4 Access Control | yes (core of the phase) | `RequiredPermissions`, action `Permissions`, D-05..D-09 hooks, `RelationLockProvider`, `Locked` options, query scopes | +| V5 Input Validation | yes | lagoon rules + `FormRules`; protected fill keys; JSON-int permission values | +| V6 Cryptography | no new use | — | + +### Known Threat Patterns for this stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Mass assignment of `is_superuser`/`role_id`/`permissions` | Elevation | `protectedFillKey`; only the controller sets them, after the checks | +| Hidden-field bypass (a non-superuser posts `is_superuser`) | Elevation | Presence check on the virtual value → 403 (D-05) | +| Self-escalation through your own role or permissions | Elevation | D-08 checks (snapshot in `FormAfterUpdate`; `AdminSetPermissionValues`) | +| Granting above your own permissions | Elevation | D-07 locks | +| Account takeover of a higher-privileged admin by password reset | Elevation | Open Question 1 "outranks" refusal | +| Removing the last superuser (lockout) | Denial of service | `guardLastSuperuser` under an advisory lock | +| Superuser enumeration by id | Information disclosure | `FormExtendQuery` scope → 404 | +| A scoped personal token used for superuser-only operations | Elevation | `actsAsSuperuser` requires an unscoped or full scope | +| A contributed action skipping the screen's permission | Elevation | `protect()` checks the controller's permissions before the action's own | + +## Sources + +### Primary (HIGH confidence, read this session) +- `modules/cabana/{contracts,crud,extension,registry,http,actions,auth,commands,field_permission,relation_field,filter_schema,form_schema,settings,navigation,tx_context,model_fields,account,token_guard,messages,query,list_schema,schema_types}.go` +- `modules/pact/capabilities.go`, `modules/party/registry.go`, `modules/bouncer/context.go`, `modules/lagoon/{backend_admin_migrations,validate,fill}.go`, `modules/surf/{router,admin_prefix_test}.go` +- `gorm.io/gorm@v1.31.2` `schema/schema.go`, `callbacks/associations.go` (module cache) +- `admin/src/components/form/{formState.ts,fields/PermissionEditorField.vue,fields/SwitchField.vue}`, `admin/src/views/PreviewView.vue` +- sm-user-plugin Users screen: `fonoteka.go/plugins/golem15/user/controllers/` and `sm-grzybyfunkcjonalne-app/plugins/golem15/user/{controllers,classes/privileged.go}` +- WinterCMS: `examples/golem15-wintercms-starter/modules/backend/{controllers/Users.php,controllers/UserRoles.php,models/UserRole.php,models/User.php,formwidgets/PermissionEditor.php,models/user/*.yaml,models/userrole/*.yaml,controllers/users/*.yaml,lang/en/lang.php}` +- Planning: 19.6.1 CONTEXT, 19.7 CONTEXT/RESEARCH, 19.6 CONTEXT and 19.6-01-PLAN, 19.8-04-PLAN, `docs/backend/users-and-permissions.md`, `docs/backend/admin-controllers.md` + +### Secondary (MEDIUM) +- PostgreSQL advisory lock functions: postgresql.org/docs/current/functions-admin.html (cited from documentation knowledge; not fetched this session) + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH. No new dependencies; versions were read from go.mod and the local toolchain. +- Architecture: HIGH for the mechanics, including save order, protected keys, lookups and activation. MEDIUM for the recommended shapes (the seam names, the unexported hooks and the snapshot field), which are discretion items. +- Pitfalls: HIGH. Each pitfall traces to a cited line. + +**Research date:** 2026-10-21 +**Valid until:** 2026-11-04. cabana is under active change (19.6, 19.7 and 19.8 are in flight), so re-check `crud.go` save order and `Activate` before executing.