18 KiB
phase, fixed_at, review_path, iteration, findings_in_scope, fixed, already_fixed, skipped, status
| phase | fixed_at | review_path | iteration | findings_in_scope | fixed | already_fixed | skipped | status |
|---|---|---|---|---|---|---|---|---|
| 09-backend-admin-authentication-and-schema-pipeline | 2026-10-01T21:50:00+02:00 | .planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-REVIEW.md | 1 | 20 | 19 | 1 | 0 | all_fixed |
Phase 9: Code Review Fix Report
Fixed at: 2026-10-01
Source review: .planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-REVIEW.md
Iteration: 1
Scope: critical_warning (CR-01 and WR-01 to WR-19; the 12 Info findings were out of scope)
Summary:
- Findings in scope: 20
- Fixed: 19
- Already fixed before this run: 1 (CR-01)
- Skipped: 0
The review predates Phase 10.1, 10.2 and 11, so every finding was re-checked against HEAD before it was touched. Paths in the review (cabana/..., bouncer/...) now live under modules/ in summercms.go. Application code lives in the sibling repo fonoteka.go. Both repos were edited and committed separately. No co-author trailers were added.
Verification ran in the main checkout (workflow.use_worktrees is false), not in an isolated worktree, so the numbers below are reproducible from the working tree.
Fixed Issues
CR-01: Writable numeric fields cannot be saved; bad field values return 500 instead of 422
Status: already fixed, no change made
Evidence: Phase 10.1 commits c3efbc3 (fill numeric model fields from JSON numbers) and e60e697 (answer a value that does not fit its column with a 422). TestCRUDFillTypeIsValidation in modules/cabana/crud_test.go pins it.
WR-01: Required-permission wildcards never match, and multiple required codes use ALL rather than Winter's ANY
Repo: summercms.go
Commit: b4b8b5d
Files modified: modules/cabana/contracts.go, modules/cabana/permissions_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md
Applied fix: cabana.Allows now ports Winter's hasAnyAccess/hasPermission: ANY of the listed codes, a trailing .* or leading * on the required code is met by any grant that matches it (excluding the bare prefix, as Winter does), and a grant ending in .* still covers every code under it. A 17-case table test pins both sides. Docs and README describe the new semantics.
Status: fixed: requires human verification (behavioural change from ALL to ANY for multi-code requirements)
WR-02: Navigation shows a denied parent item, including its label and target controller
Repo: summercms.go
Commit: 28aa073
Files modified: modules/cabana/navigation.go, modules/cabana/permissions_test.go, docs/backend/users-and-permissions.md
Applied fix: Like Winter's NavigationManager, a main item the principal may not open is dropped whatever its children allow. An allowed parent whose own controller the principal cannot open is repointed to its first openable child (or to an empty target), so the menu never links to a 403 page.
Status: fixed: requires human verification (the repoint rule goes slightly beyond Winter, which keeps the fixed parent URL)
WR-03: Create, update and delete are not gated by the compiled list and form declarations
Repo: summercms.go
Commit: f2ab93f
Files modified: modules/cabana/registry.go, modules/cabana/http.go, modules/cabana/crud_lifecycle_test.go, modules/cabana/crud_test.go, modules/cabana/relation_field_test.go, modules/cabana/security_coverage_test.go, modules/cabana/README.md, docs/backend/admin-controllers.md
Applied fix: CompiledController.operationDeclared decides what the server accepts: create needs a form and create in toolbar.buttons; update and single-record delete need a form (the SPA's form screen carries the delete button, as in Winter, so single delete is not tied to the list toolbar); bulk delete needs delete in toolbar.buttons, which already requires showCheckboxes. A write that is not declared answers 403 forbidden. Test fixtures that exercised writes were given the toolbar they implicitly assumed.
Status: fixed: requires human verification (interpretation of "declared" for single-record delete, see above)
WR-04: A form field that is required but limited by context makes every create fail
Repo: summercms.go
Commit: b808408
Files modified: modules/cabana/crud.go, modules/cabana/crud_test.go
Applied fix: mergedRules takes the operation and applies the form-level required only when contextAllows holds. The test fails without the fix.
Status: fixed: requires human verification
WR-05: Relation link and unlink ignore the panel's toolbarButtons
Repo: summercms.go
Commit: 9bca815
Files modified: modules/cabana/http.go, modules/cabana/permissions_test.go, docs/backend/relation-manager.md
Applied fix: relationMutation answers 403 forbidden unless the relation's view.toolbarButtons contains the requested action (the SPA reads view.toolbarButtons only). An unknown relation still gets the service's 404.
WR-06: A relation list without an explicit sort fails when the first column is not sortable
Repo: summercms.go
Commit: 889250c
Files modified: modules/cabana/relation.go, modules/cabana/relation_test.go
Applied fix: With no sort the first sortable column is used, or none (primary key order). Only an explicitly requested sort is validated.
Status: fixed: requires human verification
WR-07: Relation column order depends on a fixed 16-space YAML indentation
Repo: summercms.go
Commit: b026aec
Files modified: modules/cabana/relation.go, modules/cabana/schema.go, modules/cabana/relation_test.go
Applied fix: config_relation.yaml is decoded with yaml.UseOrderedMap() (new decodeStrictOrdered), so nested maps stay ordered through the per-entry re-marshal; orderRelationColumns is deleted. Tests cover 2-space and 4-space files and different orders in the view and manage panels; the 2-space test fails without the fix.
WR-08: List search returns 500 for non-text searchable columns, and the scaffold creates one
Repo: summercms.go
Commit: 8b48eea
Files modified: modules/cabana/query.go, modules/cabana/query_test.go
Applied fix: Search compiles to LOWER(CAST(col AS TEXT)) LIKE ? ESCAPE '\'. A real-PostgreSQL test searches an integer, a timestamp, a switch and a text column. The make:admin-controller stub keeps id: searchable: true: with the cast it no longer errors, and removing it would make every search a 422 because the toolbar search would have no searchable column.
WR-09: Scaffolded admin controllers have no permissions and no record source
Repo: summercms.go
Commit: 20a79c5
Files modified: internal/build/stubs/artifacts.tmpl, internal/build/artifact.go, internal/build/build_test.go, docs/console/scaffolding.md, docs/backend/admin-controllers.md
Applied fix: The generated controller now implements pact.AdminRecordSource (NewRecord returns nil with a TODO, so the list and writes answer 500 until the model is returned) and pact.AdminPermissioned with <plugin>.access_<name>. Until the plugin declares that permission, cabana.Activate refuses to start with an unknown-permission error naming the controller, so the scaffold can never be open to every administrator. IN-12 (the "DO NOT EDIT" header) was deliberately not changed: docs/setup/porting-a-plugin.md documents that the header must stay because registry.gen.go is rebuilt from files that carry it.
WR-10: Cabana silently reuses any guard already registered under the name "backend"
Repo: summercms.go
Commits: 3f47616 (fail boot on a foreign guard), 2d96adf (follow-up)
Files modified: modules/cabana/http.go, modules/cabana/backend_guard_collision_test.go, modules/bouncer/registry.go, modules/bouncer/registry_test.go, modules/bouncer/README.md, modules/cabana/README.md, docs/backend/users-and-permissions.md
Applied fix: Activate registers its own guard and fails boot, naming the owner, when another plugin already owns backend. The first commit also failed when cabana itself had registered the guard on an earlier Activate of the same application, which the Fonoteka assembly tests do (surf.Assemble then surf.BuildRouter). The follow-up adds bouncer.Registry.Owner and keeps cabana's own guard in that case. The intermediate commit 3f47616 therefore passed the framework suite but broke three Fonoteka tests; the follow-up restores them.
WR-11: Login identifier can resolve to the wrong admin; admin:create does not prevent login/email collisions
Repo: summercms.go
Commit: 331351a
Files modified: modules/cabana/auth.go, modules/cabana/commands.go, modules/cabana/auth_test.go, modules/cabana/commands_test.go, docs/backend/users-and-permissions.md
Applied fix: findBackendLogin fetches up to two matches and treats ambiguity as not found, so the request takes the same dummy bcrypt path and answers a plain 401. admin:create checks login IN (login, email) and lower(email) IN (lower(login), email). Tests cover the ambiguous login (both admins locked out by that identifier, each still able to sign in by the other field) and three collision cases.
Not done: the suggested unique index on lower(email). See "Decisions needed" below.
Status: fixed: requires human verification
WR-12: The dummy hash cost is fixed at 10 while real hashes use the configured cost (timing oracle)
Repo: summercms.go
Commit: eb8c727
Files modified: modules/cabana/auth.go, modules/cabana/auth_internal_test.go
Applied fix: The unknown-login hash is built lazily per service cost from admin.password.bcrypt_cost (cached per cost, so copying a service value stays go vet clean).
WR-13: Admin passwords are passed as command-line flags
Repo: summercms.go
Commit: c9bb149
Files modified: modules/cabana/commands.go, modules/cabana/commands_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md, docs/console/setup-and-maintenance.md
Applied fix: Without --password, both commands read the password through bonfire.Output.Secret: a hidden prompt on a terminal, one line from stdin otherwise, so scripts pipe it. --password is kept because the docs and tests use it, but it prints a deprecation warning that never echoes the value. Documented examples now use the prompt and a piped example. There is no second "confirm password" prompt, because bonfire.Output cannot tell a terminal from a pipe and a second read would break piped input.
WR-14: Logout cannot revoke a token whose access lifetime has expired but whose refresh window is still open
Repos: summercms.go and fonoteka.go
Commits: 299d220 (summercms.go), c45fb99 (fonoteka.go)
Files modified: modules/bouncer/refresh.go, modules/bouncer/refresh_test.go, modules/bouncer/README.md, modules/cabana/auth.go, modules/cabana/http.go, modules/cabana/auth_test.go, modules/cabana/security_coverage_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md; fonoteka.go plugins/golem15/fonoteka/admin_phase09_security_test.go
Applied fix: New bouncer.VerifyRefreshableClaimsAudience verifies signature and audience with exp unchecked and accepts the token while iat + refresh_ttl is open. Logout is mounted outside the backend guard (still behind the CSRF header check), verifies the token with that function, blacklists its jti, and expires the cookie on every outcome including a 401. The route inventories in both repos list logout as public, with the reason. The test mints an expired token, logs it out, and shows refresh then fails.
Status: fixed: requires human verification
WR-15: The editors pivot granted_by stores a backend_users id in a frontend-user column
Repo: fonoteka.go
Commit: b925dd6
Files modified: plugins/golem15/fonoteka/controllers/collections_admin_controller.go, plugins/golem15/fonoteka/admin_collections_test.go
Applied fix: An admin link leaves granted_by NULL (the column is nullable INTEGER, the model field is *uint, so no schema or migration change is involved) and granted_by is no longer a hook-writable pivot column. The existing test, which asserted a non-nil value, now asserts NULL. The other option in the review (resolve the backend email to a frontend user id) was not chosen: it needs a database lookup inside the hook and ties the audit column to a heuristic mapping.
WR-16: Reflection helpers skip embedded structs and fall back to case-insensitive Go field names
Repo: summercms.go
Commit: 629fac4
Files modified: modules/cabana/model_fields.go (new), modules/cabana/model_fields_test.go (new), modules/cabana/http.go, modules/cabana/crud.go, modules/cabana/query.go, modules/cabana/list_schema.go, modules/cabana/filter_schema.go, modules/cabana/relation_field.go, docs/backend/admin-controllers.md
Applied fix: One modelFields helper flattens anonymous structs (not time.Time, Scanner or Valuer types) with index paths, and fieldByColumn, modelColumns, primaryColumn, castPK, the list and filter model contracts and structFieldByColumn use it. A field with an explicit column: tag is matched by that tag alone; an untagged field matches its Go name case-insensitively or GORM's default column name. primaryColumn reads primarykey case-insensitively (gorm.Model writes it lower-case) and returns the snake-case column. The review's "use GORM's parsed schema" option was not taken, to avoid changing which columns existing models expose.
Status: fixed: requires human verification
WR-17: User-level backend_users.permissions is ignored, so Winter denies are lost at cutover
Repo: summercms.go
Commit: 8479def
Files modified: modules/cabana/auth.go, modules/cabana/auth_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md
Applied fix: BackendUsers.FindByID overlays the user's own permissions on the role's and the code-declared role grants: the user's value for a code replaces the role's and only 1 grants, so -1 and 0 remove a role grant and 1 adds one. This follows Winter's getMergedPermissions, which compares codes exactly, so denying acme.a does not take it back from a role that grants acme.*; the review asked for denies to cover wildcard matches too, but that would diverge from Winter, and the port rule is to match Winter. A -1 on the wildcard key itself removes the wildcard. Seven cases are tested on real PostgreSQL.
Status: fixed: requires human verification (the wildcard-deny nuance)
WR-18: The phase gate's zero-test check applies per invocation, not per package
Repo: summercms.go
Commit: bbdc7a6
Files modified: scripts/check-phase9.sh
Applied fix: phase9_detect tracks passing tests per Package and refuses when any package in the stream has none. --self-test gained a positive two-package case and a negative case (one package with a pass, one with only a package-level pass). --security and --openapi were run against the real suites and pass.
WR-19: Plugin hooks and scopes query outside the CRUD transaction
Repos: summercms.go and fonoteka.go
Commits: 4ae272e (summercms.go), e62f4fc (fonoteka.go)
Files modified: modules/cabana/tx_context.go (new), modules/cabana/crud.go, modules/cabana/relation.go, modules/cabana/crud_lifecycle_test.go, modules/cabana/README.md, docs/backend/admin-controllers.md; fonoteka.go plugins/golem15/fonoteka/controllers/albums_admin_controller.go, plugins/golem15/fonoteka/controllers/collections_admin_controller.go, plugins/golem15/fonoteka/controllers/request_db.go (new), plugins/golem15/fonoteka/admin_albums_test.go
Applied fix: cabana puts the write's transaction on the context inside every lagoon.Transaction callback and exports cabana.TxFromContext(ctx). The hook and scope signatures are unchanged, so no plugin contract breaks. The Fonoteka album and collection hooks read through it via a small requestDB helper and fall back to the pool outside a transaction (the list). The new Fonoteka test limits the pool to one connection and creates and updates an album; it hangs (and fails after 20 s) without the fix.
Decisions (resolved 2026-10-01, approved by the user)
- WR-11, unique index on
lower(email): added in2da8112(summercms.go). The new migration202610010001_backend_users_email_ci_uniquecreatesbackend_users_email_lower_unique. Rows copied from Winter are not changed automatically: when emails differ only in case, the migration refuses to run and names the clashing logins, so the operator changes or removes one and runsmigrateagain. Picking an admin account to drop is not something a migration should do. Tests:TestBackendAdminEmailCaseInsensitiveUniqueandTestBackendAdminEmailIndexRefusesCaseDuplicates. Documented inmodules/lagoon/README.mdanddocs/backend/users-and-permissions.md. - WR-03, single-record delete: kept as Winter. It stays allowed whenever a form exists, because the form screen's delete button matches Winter, and the admin API shape stays unchanged.
- WR-17, deny versus wildcard: kept as Winter. An exact-code deny does not remove a wildcard grant, matching Winter's
getMergedPermissions.
Test results
All run in the main checkout after the last commit; both working trees are clean.
| Command | Where | Result |
|---|---|---|
go vet ./... |
summercms.go |
clean |
go test ./... |
summercms.go |
all packages ok |
scripts/check-phase9.sh --self-test |
summercms.go |
passed |
scripts/check-phase9.sh --security and --openapi |
summercms.go |
passed |
go run ./cmd/summer docs:build --check, go test ./cmd/summer -run TestDocsTree |
summercms.go |
no problems, ok |
go vet ./... and go test ./... |
fonoteka.go root |
ok (root, parity) |
go vet ./... and go test ./... |
fonoteka.go/plugins/golem15/fonoteka and .../user |
ok |
go test ./... from the fonoteka.go workspace root does not descend into the plugin modules, so they were run from their own directories as well.
examples/hello (a separate workspace module outside the root ./...) fails TestTypedItemRoute with "config http.body_limits.default_bytes is required". It fails the same way at the pre-fix baseline 1a878b3, so it is unrelated to this run.
Fixed: 2026-10-01 Fixer: Claude (gsd-code-fixer) Iteration: 1