diff --git a/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md new file mode 100644 index 00000000..4bdf5095 --- /dev/null +++ b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md @@ -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