--- phase: 09-backend-admin-authentication-and-schema-pipeline plan: 01 subsystem: auth tags: [jwt, postgres, gorm, admin, cabana, bouncer] requires: - phase: 06-http-routing-auth-groups-and-rate-limiting provides: named bouncer guards and raw route groups - phase: 07-user-plugin-and-authentication provides: HS256 mint/verify, bcrypt, and the jti blacklist provides: - Audience-separated frontend and backend JWTs - Framework backend_users and backend_user_roles tables - Cabana raw admin login, list schema, and record-list routes - One compiled Genre list served from embedded Winter YAML affects: [09-backend-admin-authentication-and-schema-pipeline, admin-api, phase-10-spa] actuals: tokens: 17027 tasks: 2 commits: 3 tech-stack: added: [] patterns: - "Admin routes mount from surf.BuildRouter only when a plugin registers admin controllers" - "D-10 envelopes stay inside cabana; frontend 401 bodies stay PHP-shaped" - "Plugin AdminFS plus AdminRecordSource keep table names and permission codes out of cabana" key-files: created: - cabana/http.go - cabana/auth.go - cabana/schema.go - cabana/registry.go - cabana/contracts.go - lagoon/backend_admin_migrations.go - bouncer/audience_test.go - cabana/security_test.go modified: - bouncer/jwt.go - bouncer/mint.go - bouncer/refresh.go - bouncer/context.go - pact/capabilities.go - surf/router.go - lagoon/migrations.go key-decisions: - "Frontend verification still accepts PHP tokens that omit aud, and rejects any explicit audience other than user" - "Backend tokens require aud=backend, use admin.jwt.secret, and omit the PHP user prv lock-subject" - "Empty admin.jwt.secret fails router assembly only when admin controllers are registered; there is no fallback secret" - "golang-jwt emits a one-element aud array; both guards accept that form" patterns-established: - "Pattern: guard, then controller lookup, then RequiredPermissions, then schema or SQL" - "Pattern: Winter list YAML is compiled once at activation with DisallowUnknownField" requirements-completed: [AUTH-08, ADMIN-02] coverage: - id: D1 description: A developer-role backend admin logs in and reads one persisted Genre through the compiled admin list. requirement: ADMIN-02 verification: - kind: integration ref: plugins/golem15/fonoteka/admin_tracer_test.go#TestAdminTracerGenreList/genre_list status: pass human_judgment: false - id: D2 description: An empty admin.jwt.secret fails router assembly with a named configuration error. requirement: AUTH-08 verification: - kind: integration ref: plugins/golem15/fonoteka/admin_tracer_test.go#TestAdminTracerGenreList/empty_secret status: pass human_judgment: false - id: D3 description: Frontend and backend guards reject each other's audience even when the HMAC secret matches. requirement: AUTH-08 verification: - kind: unit ref: bouncer/audience_test.go#TestAudienceCrossover status: pass human_judgment: false - id: D4 description: Missing and invalid admin credentials return unauthenticated 401 before a missing controller can be distinguished, and bodies do not echo secrets or tokens. requirement: AUTH-08 verification: - kind: integration ref: plugins/golem15/fonoteka/admin_tracer_test.go#TestAdminTracerAuthBoundary status: pass - kind: unit ref: cabana/security_test.go#TestSecretRedaction status: pass human_judgment: false - id: D5 description: A non-superuser without the Genre permission receives forbidden 403 before schema or SQL, and a superuser can list. requirement: ADMIN-02 verification: - kind: unit ref: cabana/security_test.go#TestAuthorizationOrder status: pass - kind: integration ref: plugins/golem15/fonoteka/admin_tracer_test.go#TestAdminTracerPermissionBoundary status: pass human_judgment: false duration: 26min completed: 2026-09-24 status: complete plan_head_before: 01d3871510f503f07e89282aa5501022cc4839af plan_head_after: 18b2e851063f3a50b7a13c87413ac4ae3ece9998 --- # Phase 9 Plan 01: Separate-admin Genre list tracer Summary **A backend administrator logs in with a distinct JWT audience and reads one real Genre row through a boot-compiled Winter list schema on PostgreSQL.** ## Performance - **Duration:** 26 min - **Started:** 2026-09-24T14:55:33Z - **Completed:** 2026-09-24T15:21:30Z - **Tasks:** 2 - **Files modified:** 26 ## Accomplishments - Backend identity lives in Winter-shaped `backend_users` and `backend_user_roles` tables, seeded with the developer and publisher system roles, and is never read from frontend `users`. - `surf.BuildRouter` activates cabana only when a plugin registers admin controllers, and refuses an empty `admin.jwt.secret` instead of borrowing the user secret. - `POST /_admin/api/v1/auth/login` accepts a login or a normalized email, and `GET /_admin/api/v1/golem15/fonoteka/genres` returns the persisted row plus list meta from the embedded `columns.yaml`. - The same HMAC secret cannot move a token across the frontend and backend guards. Permission denial happens before the list callback. ## Task Commits Each task was committed atomically. SummerCMS `commits: 3` is `git rev-list --count` from the plan ledger. Fonoteka commits are in the sibling repository. 1. **Task 1: Genre list tracer (RED)** - `9defed3` (test, fonoteka.go) login was 404 and an empty admin secret still assembled 2. **Task 1: Genre list tracer (GREEN)** - `dfa00f7` (feat, summercms.go) and `a87eacc` (feat, fonoteka.go) 3. **Task 2: Token and permission boundaries (RED)** - `8c1de83` (test, summercms.go) and `195c95c` (test, fonoteka.go) 4. **Task 2: Token and permission boundaries (GREEN)** - `18b2e85` (feat, summercms.go) frontend guard rejects a backend audience **Plan metadata:** pending docs commit ## Files Created/Modified - `cabana/http.go` - raw admin login, list-schema, and record-list routes - `cabana/auth.go` - backend user model, login lookup, and principal grants - `cabana/schema.go` - strict compile of `config_list.yaml` and `columns.yaml` - `cabana/registry.go` - immutable controller registry - `cabana/contracts.go` - D-10 envelopes and permission matching - `lagoon/backend_admin_migrations.go` - backend identity tables and system roles - `bouncer/jwt.go` - audience-aware verify and backend guard - `bouncer/mint.go` / `bouncer/refresh.go` - audience-preserving mint and refresh - `pact/capabilities.go` - admin asset, permission, navigation, settings, and hook contracts - `surf/router.go` - activates cabana before route wrapping - `fonoteka.go` `admin.go`, Genre controller, and embedded list YAML - `fonoteka.go/config/admin.yaml` - empty `admin.jwt.secret` with no production default ## Decisions Made - Legacy frontend tokens with no `aud` claim stay valid. A present audience must be `user` on the frontend guard and `backend` on the backend guard. - Backend tokens do not carry the PHP user `prv` hash. - Admin route assembly is skipped when no plugin implements `HasAdminControllers`, so existing surf tests do not need an admin secret. - golang-jwt v5 writes `aud` as a JSON array even for one value. The tracer accepts that encoding. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Admin test secret added to every shared router config helper** - **Found during:** Task 1 (Genre list tracer) - **Issue:** Once the Genre controller is registered, every full-app `BuildRouter` needs `admin.jwt.secret`. The plan named `plugin_boot_test.go` and `parity/migrate_test.go`, but `routes_cors_test.go` and `parity/genre_security_test.go` assemble the same router through their own helpers. - **Fix:** Set `SUMMER_ADMIN__JWT__SECRET` to the test-only admin secret in those helpers too. It is not the user secret and it is not a production default. - **Files modified:** `plugins/golem15/fonoteka/routes_cors_test.go`, `parity/genre_security_test.go`, plus the two helpers the plan named - **Verification:** `go test ./plugins/golem15/fonoteka -count=1` passed - **Committed in:** `a87eacc` (fonoteka.go) --- **Total deviations:** 1 auto-fixed (1 blocking) **Impact on plan:** The extra helpers are the same test-secret change the plan required. No production secret was added and no module dependency changed. ## TDD Gate Compliance | Gate | Commit | Result | |------|--------|--------| | RED task 1 | `9defed3` test(09-01) | `TestAdminTracerGenreList` failed on login 404 and a nil empty-secret error | | GREEN task 1 | `dfa00f7` / `a87eacc` feat(09-01) | tracer passed on real PostgreSQL | | RED task 2 | `8c1de83` test(09-01) | `TestAudienceCrossover` failed because the frontend guard accepted a backend token | | GREEN task 2 | `18b2e85` feat(09-01) | audience, authorization-order, and boundary tests passed | `gsd_run check tdd-red-evidence` returned `RED_EVIDENCE_OK` for both RED runs. Go's test output is not TAP, so each evidence record appends a TAP trailer that names the test the go harness actually failed. ## Authentication Gates None. ## Issues Encountered None. ## User Setup Required None - no external service configuration required. Production boots that register admin controllers must set `SUMMER_ADMIN__JWT__SECRET`. `config/admin.yaml` ships the key empty on purpose. ## Next Phase Readiness Ready for 09-02. The cabana registry, backend principal, audience-aware bouncer helpers, and D-10 envelope are the skeleton later plans extend. `AUTH-08` and `ADMIN-02` stay shared with later plans in this phase, so they are not marked complete yet. ## Self-Check: PASSED - FOUND: cabana/http.go, cabana/auth.go, cabana/schema.go, cabana/registry.go, cabana/contracts.go, lagoon/backend_admin_migrations.go - FOUND: fonoteka admin.go, genres admin controller, config_list.yaml, columns.yaml, admin_tracer_test.go, config/admin.yaml - FOUND commits: 9defed3, dfa00f7, a87eacc, 8c1de83, 195c95c, 18b2e85 --- *Phase: 09-backend-admin-authentication-and-schema-pipeline* *Completed: 2026-09-24*