A backend administrator can log in with a backend credential and read one real Genre list through the raw admin API; the request passes the backend guard, controller permission, compiled columns schema, GORM/PostgreSQL, and the D-10 JSON envelope.
Frontend and backend JWTs carry and require distinct audiences and secrets per D-02; swapping either token across guards returns 401 before controller lookup.
Every tracer admin path checks D-03 RequiredPermissions before schema resolution or database access; a non-superuser without the Genre permission gets the fixed 403 envelope.
statement
verification
[FLAGGED ASSUMPTION — AUTH-08 edge probe] The login identifier accepts either backend login or normalized email, uses one opaque invalid-credentials response, and never falls back to a frontend user record.
backstop
path
provides
cabana/http.go
Framework-wide raw admin route activation and D-10 envelopes
path
provides
cabana/auth.go
Backend principal provider, login handler, and backend audience guard
path
provides
cabana/schema.go
Boot-compiled list schema used by the tracer query
path
provides
lagoon/backend_admin_migrations.go
Framework-owned backend identity tables and system roles
BuildRouter activates cabana before plugin route wrapping
cabana
from
to
via
pattern
cabana/auth.go
bouncer/jwt.go
backend guard verifies the backend audience with the admin secret
AudienceBackend
from
to
via
pattern
cabana/http.go
cabana/schema.go
Genre list resolves only a boot-compiled controller schema
Schema
from
to
via
pattern
cabana/http.go
gorm.io/gorm
compiled Genre model/table query
gorm.DB
[FLAGGED-UNVERIFIED] Backend admin identities must not share frontend user rows, the frontend jwt guard, or a common signing secret.
[FLAGGED-UNVERIFIED] Permission-hidden navigation must not substitute for server-side authorization on controller and schema paths.
[FLAGGED-UNVERIFIED] The tracer must not be a mock-only or in-memory path; PostgreSQL persistence and the assembled router are required.
Phase Goal
As a backend administrator, I want to authenticate separately and manage resources described by Winter-shaped schemas, so that the administration surface stays permission-gated and reusable without coupling it to frontend users.
Prove the Phase 9 architecture with one production end-to-end Genre list before adding breadth.
Purpose: The tracer exercises the hardest constraint first: a distinct backend principal must cross authentication, permissions, strict embedded YAML compilation, generic handling, PostgreSQL, and the stable JSON contract without importing Fonoteka knowledge into the framework. The deterministic assumption delta is no-change: per D-01/D-02, backend identity remains a distinct principal/role model, not an add-alongside generalization of frontend users.
Output: Audience-aware bouncer primitives, backend tables, the cabana registry/auth/schema/HTTP skeleton, one real Genre controller schema, and an assembled real-PostgreSQL tracer test.
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md
@.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-RESEARCH.md
@bouncer/jwt.go
@bouncer/mint.go
@pact/capabilities.go
@lagoon/migrations.go
@surf/router.go
@../fonoteka.go/plugins/golem15/fonoteka/plugin.go
@../fonoteka.go/plugins/golem15/fonoteka/models/genre.go
Existing contracts to preserve: bouncer.Registry.Register/Middleware, bouncer.UserProvider.FindByID, pact.AdminController.ID/ModelName/ConfigDir, pact.HasAdminControllers.AdminControllers, lagoon.Migrate, and surf.BuildRouter. Add backward-compatible audience-specific JWT entry points so existing frontend call sites keep their public function names while defaulting to the frontend audience.
pact.AdminAssets, pact.AdminPermissioned, and the complete shared admin capability/hook contracts consumed by later plans
cabana.Registry, cabana.CompiledController, cabana.BackendUser, cabana.BackendUserRole, admin envelope helpers, and route activation
lagoon.BackendAdminMigrations
Fonoteka genresAdminController, embedded admin FS, and real Genre list/columns assets
Task 1: Deliver the separate-admin Genre list tracer end to end
D-01/D-02/D-09 establish shared database and wire contracts consumed by the Phase 10 SPA and later plugins; changing them requires coordinated callers, but the decisions are already locked and need no checkpoint.
bouncer/context.go, bouncer/jwt.go, bouncer/mint.go, bouncer/refresh.go, pact/capabilities.go, lagoon/backend_admin_migrations.go, cabana/contracts.go, cabana/registry.go, cabana/schema.go, cabana/auth.go, cabana/http.go, surf/router.go, ../fonoteka.go/config/admin.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/genre/columns.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_tracer_test.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go, ../fonoteka.go/parity/migrate_test.go
bouncer/registry.go, bouncer/jwt.go, bouncer/mint.go, lagoon/migrations.go, pact/capabilities.go, surf/router.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/models/genre.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go, ../fonoteka.go/parity/migrate_test.go, .planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md
- Test 1: migrate a fresh Testcontainers PostgreSQL database, insert an activated backend admin in the developer role and a Genre, log in through `/_admin/api/v1/auth/login`, then GET the Genre list and receive the row in `data` plus list metadata.
- Test 2: GET the list schema and observe columns compiled from the embedded Winter-shaped `columns.yaml`; the list handler uses that same compiled schema rather than caller-supplied identifiers.
- Test 3: empty `admin.jwt.secret` fails router assembly with a named configuration error; no fallback secret is used.
Start with a failing assembled `TestAdminTracerGenreList`, then implement the thinnest permanent D-01 through D-11 path. Add Winter-shaped backend user/role tables and developer/publisher system-role seeds to the framework migration set; add distinct frontend/backend audience claims while retaining backward-compatible frontend bouncer entry points; define the cabana controller registry and capability contracts; compile the real Genre `config_list.yaml` and `columns.yaml` from the plugin's embedded FS; mount the D-09 raw admin login, list-schema, and record-list routes from `surf.BuildRouter`; authenticate via the named `backend` bouncer guard and `admin.jwt.secret`; enforce the Genre controller permission before registry/schema/database work; query the real GORM Genre model; and write only the D-10 envelope. Keep cabana app-agnostic: all model factories, permission codes, table selection, and YAML assets come through registered plugin/controller contracts. Add the admin test secret only to shared test configuration helpers, never as a production default.
(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestAdminTracerGenreList$' -count=1)
The command exits non-zero, reports no matching test, router assembly accepts an empty admin secret, login does not produce a backend-audience token, the request bypasses permission evaluation, YAML is not compiled, or the persisted Genre is absent from the D-10 envelope.
The real assembled route proves authentication, permission, strict schema lookup, PostgreSQL read, and JSON serialization in one test; the implementation contains no app-specific table or permission constant in `cabana`.
A developer-role backend admin can authenticate separately and retrieve the real Genre through the compiled admin path.
Task 2: Make the tracer fail closed across token and permission boundaries
bouncer/audience_test.go, cabana/security_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_tracer_test.go
bouncer/jwt.go, bouncer/mint.go, bouncer/registry.go, cabana/auth.go, cabana/http.go, cabana/registry.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_tracer_test.go
- Test 1: a frontend-audience token is rejected by the backend guard and a backend-audience token is rejected by the frontend guard, even if a test deliberately reuses one secret.
- Test 2: missing/invalid credentials return `unauthenticated` 401 before controller existence can be distinguished.
- Test 3: an activated non-superuser without `golem15.fonoteka.access_genres` gets `forbidden` 403, while a superuser succeeds; permission checks precede schema and SQL access.
Add focused bouncer and cabana regressions for T-09-01 and T-09-02. Verify audience as well as HS256/expiration/subject, keep secrets per guard, and structure the protected handler wrapper in the exact order guard → controller lookup → D-03 permission evaluation → schema/query. Instrument test doubles so unauthorized requests prove the schema resolver and database callback were never invoked. Assert fixed D-10 error codes and confirm bodies/logs contain neither raw JWTs nor configured secrets.
go test ./bouncer ./cabana -run 'Test.*(Audience|Permission|AuthorizationOrder|Secret)' -count=1 && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestAdminTracer(AuthBoundary|PermissionBoundary)$' -count=1)
Either command exits non-zero, either package reports no matching tests, a crossover token is accepted, a permissionless request reaches schema/database work, error codes differ from 401/403, or a secret/token appears in captured output.
Both token-crossover directions and both authorization-order denial paths fail closed with observable regression tests.
The production tracer is protected against token confusion and missing route permission enforcement.
<threat_model>
Trust Boundaries
Boundary
Description
Client → raw admin API
Untrusted credentials, path segments, and query input enter the backend-only route family.
JWT → backend principal provider
Signed claims select a backend identity and must not cross the frontend/backend boundary.
Plugin embedded FS → schema compiler
Plugin-owned YAML becomes a server-side query/serialization contract.
Separate secrets plus required user/backend audience claims; execute both token-crossover regressions, including same-secret defense in depth.
T-09-02
Elevation
cabana protected-route wrapper
high
mitigate
Central wrapper orders authentication and D-03 permission evaluation before controller/schema/query access; execute denial-path call-count tests.
T-09-SC
Tampering
Go module dependency set
high
mitigate
This plan installs no package; keep both repositories' go.mod/go.sum unchanged and halt for the package-legitimacy protocol if an executor discovers a dependency need.
</threat_model>
- The assembled Testcontainers tracer passes and proves a real PostgreSQL row crosses every intended layer.
- Audience crossover, empty secret, unauthenticated, permissionless, and superuser cases are executable and fail in the intended direction.
- `go test ./bouncer ./cabana` and the focused Fonoteka package tests complete without adding a dependency.
<success_criteria>
A real backend admin can log in and list Genres through a schema-driven generic handler.
Frontend/backend token crossover is impossible by audience and secret.
Permission denial happens before schema/controller enumeration or SQL.
The tracer becomes the production skeleton expanded by Plans 02–10.
</success_criteria>
Create `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-01-SUMMARY.md` when done.