docs(19.6.1-01): complete backend administrators server-side plan

This commit is contained in:
Jakub Zych
2026-10-22 11:19:46 +02:00
parent 653307de0c
commit 3a772fabb1

View File

@@ -0,0 +1,292 @@
---
phase: 19.6.1-backend-administrators-screen
plan: 01
subsystem: admin
tags: [cabana, pact, admin, permissions, postgres, advisory-lock, phrasebook]
requires:
- phase: 12.1
provides: generic CRUD, record/bulk actions, permissioneditor, RelationLockProvider, preview screens
provides:
- pact.HasAdminActionExtensions / pact.AdminActionExtension (cross-controller record actions, D-14)
- internal summercms.cabana pseudo-plugin with summercms.cabana.users and summercms.cabana.roles
- backend.manage_users and backend.manage_roles permission codes (no role grants)
- server-side privilege rules D-03..D-12, D-16, D-17, D-19, D-20
- backend::lang user, user.role, permissions copy (en, pl)
affects: [19.6.1-02 (SPA trigger and record permission locks), 19.6.1-03 (unit and Postgres tests), 19.7 (link action on the Administrators screen), 19.6 D-15 (Reset 2FA contribution)]
actuals:
tokens: 41600
tasks: 4
commits: 4
plan_head_before: 88ec4df2b35dd5d6654df23c19ba5ad371fe993d
plan_head_after: 653307de0c37902b68a2acd53bef07e887e44ca8
tech-stack:
added: []
patterns:
- "Internal pseudo-plugin appended in Activate only after the no-controllers early return"
- "Boot-time merge of contributed record actions into compiled controllers before permission validation"
- "Unexported in-package interfaces (recordDataExtender, schemaFieldHider) for per-request projection and schema drops"
- "Last-superuser invariant: pg_advisory_xact_lock after the row lock plus a count in the write tx"
- "Prior-state snapshot on an unexported model field, compared in FormAfterUpdate"
key-files:
created:
- modules/cabana/action_extensions.go
- modules/cabana/backend_screens.go
- modules/cabana/backend_users_admin.go
- modules/cabana/backend_roles_admin.go
- modules/cabana/backend_privileges.go
- modules/cabana/backendscreens/controllers/users/{config_list,config_form,config_filter}.yaml, _hint.htm
- modules/cabana/backendscreens/models/user/{columns,fields}.yaml
- modules/cabana/backendscreens/controllers/roles/{config_list,config_form}.yaml
- modules/cabana/backendscreens/models/userrole/{columns,fields}.yaml
- modules/cabana/backend_screens_test.go
- modules/cabana/backend_screens_smoke_test.go
- modules/cabana/example_action_extension_test.go
modified:
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/cabana/http.go
- modules/cabana/registry.go
- modules/cabana/contracts.go
- modules/cabana/crud.go
- modules/cabana/README.md
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- docs/backend/admin-controllers.md
- docs/backend/users-and-permissions.md
key-decisions:
- "The Active filter label uses backend::lang.user.filter_activated, because UI-SPEC assigned user.activated to both the filter label and the activate success toast"
- "The D-14 seam carries record actions only; AdminActionExtension is a struct so bulk or toolbar kinds can be added later"
- "Bulk lifecycle actions return their success phrase only when every selected row changed; otherwise the framework count text (never overstates)"
- "D-17 in-use refusal is a 403 ForbiddenError whose message the hook translates with Choice (name and count), so the SPA delete toast shows it"
patterns-established:
- "Contributed record action names are checked against the owner's and earlier contributors' names; the error names both plugins"
- "Every refusal from a delete or an action on these screens is a ForbiddenError with Message set"
requirements-completed: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-20, D-22]
coverage:
- id: D1
description: "pact action-extension seam: a plugin adds record actions to summercms.cabana.users; offered on the preview after the owner's, run in the row-locked tx; unknown controller ID stops boot"
requirement: "D-14"
verification:
- kind: unit
ref: "modules/cabana/backend_screens_test.go#TestBackendScreensSmoke"
status: pass
- kind: integration
ref: "modules/cabana/backend_screens_smoke_test.go#TestBackendScreensPostgresSmoke"
status: pass
human_judgment: false
- id: D2
description: "Pseudo-plugin activates only with admin controllers; settings tile Administrators (order 100) and Roles (110) under System; codes granted to no role"
requirement: "D-01"
verification:
- kind: unit
ref: "modules/cabana/backend_screens_test.go#TestBackendScreensSmoke"
status: pass
- kind: integration
ref: "go -C examples/hello test ./... and go test ./modules/surf"
status: pass
- kind: integration
ref: "modules/cabana/backend_screens_smoke_test.go#TestRolesSmoke"
status: pass
human_judgment: false
- id: D3
description: "Administrators form: create with password, superuser projection, D-03 hiding, D-05 superuser-only switch, D-08 own role/flag refusals, D-20 own password refusal, superuser demotion with a second superuser"
requirement: "D-05"
verification:
- kind: integration
ref: "modules/cabana/backend_screens_smoke_test.go#TestAdministratorsFormSmoke"
status: pass
human_judgment: false
- id: D4
description: "Lifecycle: deactivate revokes JWTs, soft delete with deleted row state, restore, trashed filter, self delete and self bulk-deactivate refused with whole-selection rollback, preview hint"
requirement: "D-10"
verification:
- kind: integration
ref: "modules/cabana/backend_screens_smoke_test.go#TestAdministratorsLifecycleSmoke"
status: pass
human_judgment: false
- id: D5
description: "Roles screen: create, code taken, in-use and system-role delete refusals, system code lock, settings order"
requirement: "D-16"
verification:
- kind: integration
ref: "modules/cabana/backend_screens_smoke_test.go#TestRolesSmoke"
status: pass
human_judgment: false
- id: D6
description: "Concurrent last-superuser race, D-07 ceilings and D-19 outranking for limited editors, scoped-token superusers"
requirement: "D-09"
verification: []
human_judgment: true
rationale: "Implemented here; the held-lock race and the per-rule unit tests are Plan 03's scope (D-18). Only the sequential paths are smoke-tested in this plan."
- id: D7
description: "Screens render and behave in the admin SPA (lists, form tabs, preview footer, toasts)"
verification: []
human_judgment: true
rationale: "Visual and interaction behaviour in the browser is the VALIDATION manual-only UAT item; D-21 SPA additions land in Plan 02."
duration: 30min
completed: 2026-10-22
status: complete
---
# Phase 19.6.1 Plan 01: Backend Administrators and Roles screens (server side) Summary
**cabana now ships Settings → System → Administrators and Roles as YAML controllers of an internal `summercms.cabana` pseudo-plugin. Every privilege rule is enforced in the save transaction, and the last active superuser is protected by a `pg_advisory_xact_lock` plus a count. A new `pact.HasAdminActionExtensions` seam lets any plugin add record actions to any admin controller by ID.**
## Performance
- **Duration:** ~30 min
- **Started:** 2026-10-22T08:48:58Z
- **Completed:** 2026-10-22T09:18:37Z
- **Tasks:** 4
- **Files modified:** 29 (3066 insertions, 6 deletions)
## Accomplishments
- **D-14 seam.** `pact.AdminActionExtension` and `pact.HasAdminActionExtensions` were added. `compileActionExtensions` merges contributed actions after the controllers compile and before `compileContributions`, so their permissions are validated. The merge builds a new slice and a new map rather than mutating the compiled ones. Unknown controller, no preview block, reserved or duplicate names, a nil `Run` and an empty label each stop boot.
- **Pseudo-plugin.** `Activate` appends `backendScreens` only after the `len(items) == 0` early return. `examples/hello` and the surf admin-prefix tests are unchanged. The caller's plugin slice is never mutated.
- **Administrators screen (`summercms.cabana.users`).**
- List: role, superuser, activation, last-sign-in and deleted filters; deleted and disabled row states.
- Preview: status hint, then Activate / Deactivate / Restore, then contributed actions.
- Form, Account tab: login, email, names, password and confirmation, role picker.
- Form, Permissions tab: superuser switch and radio overrides.
- Bulk actions: activate, deactivate, restore; bulk soft delete.
- **Privilege rules, enforced on the server:**
- D-03: superusers are hidden from non-superusers in the list, every lookup and the schemas.
- Pitfall 1: superusers receive the stored `is_superuser` value, so an untouched save keeps the flag.
- D-05: the superuser switch is superuser-only, even when the field is hidden.
- D-07: locked permission options and role locks for editors without the grants.
- D-08: an admin cannot change their own role (checked in `FormAfterUpdate` against a snapshot), flag, overrides or activation, and cannot delete themselves.
- D-09: the last active superuser cannot be demoted, deactivated or deleted, on single and bulk paths.
- D-11: password min 4 characters, max 72 bytes, bcrypt cost from config.
- D-12: `tokens_valid_after` is bumped on a password set for another admin, on deactivate and on delete.
- D-19: an editor cannot change an admin who outranks them.
- D-20: an admin cannot set their own password here.
- Login and email must be unique ignoring case, deleted admins included.
- **Roles screen (`summercms.cabana.roles`).**
- Fields: name, slug code, description, checkbox permission editor with the editor's ceiling.
- System roles keep their code and cannot be deleted.
- A role still held by any admin cannot be deleted; the 403 names the role and the count.
- Role codes must be unique.
- **Copy and docs.** The full `backend::lang.user` (with `user.role`), `backend::lang.permissions` and `settings.category_system` copy is in en and pl. The cabana and pact READMEs, `docs/backend/admin-controllers.md` (new section "Adding record actions to another controller") and `docs/backend/users-and-permissions.md` (Permissions, the Administrators screen, lifecycle, the Roles screen, the My account paragraph) were updated in the same commits as the code.
## Task Commits
1. **Task 1 (tracer): Administrators list and preview, settings tile, cross-controller record actions.** `7340241` (feat)
2. **Task 2: Administrators form with the server-side privilege rules.** `8840173` (feat)
3. **Task 3: Administrator lifecycle and last-superuser protection.** `530b02a` (feat)
4. **Task 4: Roles screen.** `653307d` (feat)
## Files Created/Modified
- `modules/pact/capabilities.go`: `AdminActionExtension` and `HasAdminActionExtensions`.
- `modules/cabana/action_extensions.go`: the boot-time merge and its checks.
- `modules/cabana/backend_screens.go`: the pseudo-plugin (controllers, two permissions, two settings links, embedded `AdminFS`), the exported constants and `withBackendScreens`.
- `modules/cabana/backend_users_admin.go`: the Administrators controller (scopes, projection, schema hiding, rules, filter options, role locks, permission provider, Form hooks, lifecycle record and bulk actions, hint partial).
- `modules/cabana/backend_roles_admin.go`: the Roles controller.
- `modules/cabana/backend_privileges.go`: `actsAsSuperuser`, `roleGrantSet`, `effectiveGrants`, `grantsCovered`, `guardOutranked`, `guardLastSuperuser`, `superuserLockKey`, `revocationCutoff`.
- `modules/cabana/contracts.go`:
- `BackendUser`: `Fillable`, `Rules`, `FilterScopes` and `FilterScope` (`filterByRole`, `filterTrashed`), plus the `priorRoleID`/`priorLoaded` snapshot.
- `BackendUserRole`: `Fillable` and `Rules`.
- `CompiledController.contributed` and `Registry.permissionOrder`.
- `modules/cabana/registry.go`: fills `permissionOrder`.
- `modules/cabana/crud.go`: the `recordDataExtender` hook in `projectFullRecord`.
- `modules/cabana/http.go`:
- `Activate`: the pseudo-plugin and the extension merge.
- `schemaFieldHider`, applied in `formSchema` and `listSchema` (new slices).
- `modules/cabana/backendscreens/**`: the embedded YAML and the `_hint.htm` partial (copied verbatim from the analog).
- `modules/cabana/backend_screens_test.go`: `TestBackendScreensSmoke` (runs under `-short`).
- `modules/cabana/backend_screens_smoke_test.go`: `TestBackendScreensPostgresSmoke`, `TestAdministratorsFormSmoke`, `TestAdministratorsLifecycleSmoke` and `TestRolesSmoke`, plus the reusable `screensEnv` harness for Plan 03.
- `modules/cabana/example_action_extension_test.go`: the documented seam example (`ProfilesPlugin.AdminActionExtensions`) and `TestExampleActionExtension`.
## Decisions Made
- I followed the plan's recorded decisions: the IDs, the conditional pseudo-plugin, record actions only in the seam, the advisory-lock strategy, the D-07 wildcard rule, D-12 leaving personal tokens valid on a password set, the D-17 refusal as a 403, and no users-count column on Roles.
- The Active filter label key is `backend::lang.user.filter_activated` (see deviation 1).
- Bulk lifecycle actions report their fixed success phrase only when every selected row changed, as the analog does. Otherwise the framework's count text is shown.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Phrase key collision on `backend::lang.user.activated`**
- **Found during:** Task 1 (lang keys)
- **Issue:** UI-SPEC assigns `user.activated` both to the "Active" filter label and to the "Administrator activated" success toast. One key cannot hold both texts.
- **Fix:** `user.activated` keeps the success text, and the filter label uses a new key, `user.filter_activated` ("Active" / "Aktywny"). `config_filter.yaml` uses the new key.
- **Files modified:** modules/phrasebook/backend/lang/{en,pl}/lang.yaml, modules/cabana/backendscreens/controllers/users/config_filter.yaml
- **Committed in:** 7340241, 8840173
**2. [Rule 3 - Blocking] The docs checker refuses Go code blocks without `src=`**
- **Found during:** Task 1 (docs)
- **Issue:** `TestDocsTree` failed: "go code block has no src= reference". The snippet source must also be exercised by a test.
- **Fix:** I moved the neutral example to `modules/cabana/example_action_extension_test.go` (`ProfilesPlugin.AdminActionExtensions`), added `TestExampleActionExtension`, and referenced it with `src=` (`summer docs:sync`).
- **Committed in:** 7340241
**3. [Rule 2 - Missing critical] More smoke assertions than the plan lists**
- The form smoke also checks the D-20 own-password refusal, the D-08 own-role change being refused and rolled back after assignment, the 422 on a taken login, and a limited editor's rename succeeding without `is_superuser`.
- The lifecycle smoke also checks the hint partial for a deleted admin, the `trashed=only` filter, the self-delete refusal, and that bulk activate does not move `tokens_valid_after`.
- The roles smoke also checks the code-taken 422, that a system role's name stays editable, and that an unused role deletes.
- **Committed in:** 8840173, 530b02a, 653307d
**4. Commit scope.** Commits use `feat(19.6.1-01): ...`, as the orchestrator instructed, instead of the plan's `feat(cabana): ...`. There are no co-author tags.
**5. YAML detail.** The trashed scope filter declares `nameFrom: login`, because cabana requires `nameFrom` on a scope filter. The Roles list also sets `messages.empty` to `user.role.list_empty`.
---
**Total deviations:** 3 auto-fixed (1 bug, 1 blocking, 1 missing-critical test coverage), plus 2 notes.
**Impact on plan:** None on scope. The key rename is a copy-contract fix that Plan 02 and Plan 03 should reference.
## Existing tests
No existing test was modified: every pre-existing cabana, surf, pact and hello test passes unchanged with the pseudo-plugin active.
## Verification
- `go vet ./...`: pass.
- `go test -short ./...` (whole repo): pass.
- `go -C examples/hello test ./...`: pass.
- `go test -run '^TestDocsTree$' ./cmd/summer`: pass.
- `go run ./cmd/summer docs:build --check`: no problems found.
- `scripts/check-admin-openapi.sh --check`: exit 0, no swag-visible change.
- Docker-backed tests, run directly (the Docker socket was accessible):
- `go test ./modules/cabana ./modules/surf`: pass after every task.
- The four smokes `TestBackendScreensPostgresSmoke`, `TestAdministratorsFormSmoke`, `TestAdministratorsLifecycleSmoke` and `TestRolesSmoke`: 4/4 `--- PASS`.
- SC-1: `git log 88ec4df..HEAD -- modules/cabana/commands.go modules/cabana/token_commands.go modules/cabana/account.go` is empty.
- Every task's acceptance criteria were re-run and passed.
## Issues Encountered
None.
## Known Stubs
None.
## Threat Flags
None. The new surface (two admin controllers, the extension seam) is covered by the plan's threat register, T-19.6.1-01 to T-19.6.1-17.
## User Setup Required
None. No external service configuration is required.
## Next Phase Readiness
- Plan 02 (D-21 SPA) can add the field `trigger` (the Administrators `permissions` field has no trigger key yet) and the per-record permission locks: role code-declared grants, and every code on the editor's own record.
- Plan 03 can build on `screensEnv` in `backend_screens_smoke_test.go` for the held-lock D-09 race, the D-07 and D-19 cases for limited editors, scoped-token superusers and the seam's boot errors.
- 19.7 and the MFA plugin can contribute record actions to `cabana.AdministratorsControllerID` through `pact.HasAdminActionExtensions`.
---
*Phase: 19.6.1-backend-administrators-screen*
*Completed: 2026-10-22*
## Self-Check: PASSED