# Phase 9: Backend admin authentication and schema pipeline - Research
**Researched:** 2026-09-24
**Domain:** Go backend authentication, capability registry, typed YAML-to-JSON admin schemas, GORM CRUD
**Confidence:** MEDIUM
## User Constraints (from CONTEXT.md)
### Locked Decisions
#### Admin identity and login
- **D-01:** Backend admins live in Winter-shaped tables: `backend_users` (login, email, password bcrypt, first_name, last_name, is_activated, is_superuser, role_id, last_login, timestamps, soft delete) and `backend_user_roles` (name, code, description, permissions JSON, is_system). Table and column names match Winter so an existing WinterCMS `backend_users` table can be copied straight in at cutover. `backend_user_groups` is not ported. Role codes `developer` and `publisher` are seeded as system roles, matching `UserRole::CODE_DEVELOPER` / `CODE_PUBLISHER`.
- **D-02:** Admin sessions are JWTs issued by a second named guard, `backend`, registered in the Phase 6 bouncer registry beside `jwt`. It has its own secret (`admin.jwt.secret`, fail-loud when empty, as Phase 3 did for the user secret) and an audience claim so a frontend token can never authenticate as an admin and vice versa. Mint, verify, TTL config, sliding refresh and the Postgres jti blacklist from Phase 7 are reused; Phase 7's bcrypt helpers hash and check admin passwords. No cookie session store and no new dependency (`alexedwards/scs` is not adopted).
- **D-03:** Permissions keep Winter semantics. Plugins implement `HasPermissions` returning entries in the `registerPermissions()` shape (code, tab, label, roles). Checks: `is_superuser` passes everything; otherwise the admin's role `permissions` JSON is consulted; a code ending in `.*` matches by prefix, so `golem15.fonoteka.*` on the top navigation item works unchanged. An admin controller declares `RequiredPermissions() []string` and the framework enforces it (403 with the standard error envelope) before any list, form, relation or bulk-delete handler runs.
- **D-04:** The first admin is created by a framework console command, `summer admin:create --email --password [--login] [--superuser] [--role=code]`, plus `summer admin:reset-password `. Scaffolded the Phase 4 way. No boot-time seed from config and no web setup wizard.
#### Schema pipeline shape
- **D-05:** YAML layout is Winter's, not the Phase 4 scaffold's. `controllers//config_form.yaml` and `config_list.yaml` name the model and point at `models//fields.yaml` and `columns.yaml`; `config_filter.yaml` and `config_relation.yaml` sit beside them when present. The five Płytarium YAML sets are copied across unchanged apart from the one `partial` field (D-15). `summer make:admin-controller` is changed to emit this layout, and `pact.AdminController.ConfigDir()` points at the `controllers//` directory; all config is embedded through the plugin's `fs.FS`.
- **D-06:** The JSON schema is typed: Go structs per field type and per column type, decoded with goccy/go-yaml `DisallowUnknownField` so an unsupported key or field type fails loudly at boot. JSON keys keep Winter's spelling (`nameFrom`, `emptyOption`, `span`, `tab`, `context`, `attributes`, `recordsPerPage`, `showSearch`, `toolbarButtons`) so the served schema reads like the YAML that produced it. Field types in scope: text, textarea, number, checkbox, switch, dropdown, relation, relation-manager. Column features in scope: searchable, sortable, `relation` + `select`, `type: datetime`, `type: switch`.
- **D-07:** Labels, comments, tabs, `emptyOption` and navigation labels are resolved server-side through phrasebook in the admin's locale (Accept-Language, then `app.locale`). The SPA receives display strings only. A missing key falls back to the raw key, as phrasebook already does. Every schema endpoint carries the locale it resolved in `meta`.
- **D-08:** `options: ` on a dropdown maps to an options-provider interface on the model: `DropdownOptions(field string) []Option` where `Option` is value plus label key (resolved per D-07). The pipeline calls it for any dropdown whose `options:` is a string and fails at boot if the model does not implement the interface. A YAML `options:` map is also accepted and served as-is. Album ports `getFormatOptions` this way.
#### Admin CRUD API contract
- **D-09:** The admin API is a framework-wide, consistent layer for every non-parity API, owned by a new framework package and separate from the Phase 6 parity helpers in `fonoteka.go`. Paths: `/_admin/api/v1/{vendor}/{plugin}/{controller}` for records, `/_admin/api/v1/{vendor}/{plugin}/{controller}/schema/{form|list|filter}` and `.../schema/relation/{name}` for schemas, `/_admin/api/v1/auth/{login,refresh,logout,me}`, `/_admin/api/v1/navigation`, `/_admin/api/v1/settings/{code}` and `.../settings/{code}/schema`. Every handler carries swag v1 annotations so the OpenAPI document Phase 10 generates types from is complete by construction.
- **D-10:** One envelope on every endpoint. Success: `{"data": ..., "meta": {...}}`. Error: `{"error": {"code": "...", "message": "...", "details": {...}}}` with a fixed vocabulary: `unauthenticated` 401, `forbidden` 403, `not_found` 404, `validation_failed` 422 (details is field to messages), `conflict` 409. The group is mounted raw-style under the `backend` guard, so no fonoteka house middleware applies.
- **D-11:** One list contract for controller lists and relation-manager lists alike: query `search` (across `searchable` columns), `sort` and `dir` (only `sortable` columns, otherwise 422), `page`, `per_page` (default `recordsPerPage`, capped), and `filter[]=...`. Response meta is `{page, per_page, total, last_page}`. Relation columns join the related table and select the named column.
- **D-12:** Filters come from `config_filter.yaml`, referenced by `filter:` in `config_list.yaml`. Three scope shapes are ported now, because those are the shapes the Golem15 user and journal plugins use: `switch` with two conditions on a named column, `daterange` on a named column, and a model-backed scope (`modelClass`, `nameFrom`, `scope`). Raw SQL `conditions:` strings are not supported: a scope either names a model scope method (`scope: filterByGroup` resolved through a `FilterScope(name string, db *gorm.DB, value any) *gorm.DB` interface on the model) or uses the built-in switch/daterange handling on a declared column. A YAML `conditions:` key is a boot error with a message pointing at this rule. Remaining Winter scope types are deferred.
- **D-13:** Hooks are optional interfaces on the admin controller, type-asserted by the framework in the same style as pact's `Has*` capabilities: `ListExtendQuery(ctx, *gorm.DB) *gorm.DB`, `FormExtendQuery(ctx, *gorm.DB) *gorm.DB`, `FormBeforeCreate(ctx, model) error`, `FormBeforeUpdate(ctx, model) error`, `RelationExtendManageQuery(ctx, relation string, *gorm.DB) *gorm.DB`, `RelationBeforeLink(ctx, relation string, parent, related, pivot map) error`. The context carries the authenticated admin principal. Bulk delete (`POST .../bulk-delete` with `{ids}`) loads each record through `ListExtendQuery` and deletes it individually so Phase 5 lifecycle hooks and soft-delete cascades fire; it never issues a single `DELETE ... WHERE id IN`.
- **D-14:** The Albums scoping hooks resolve the admin's active collection exactly as the PHP `BackendAlbumCollectionResolver` does: the backend admin's email, lower-cased and trimmed, must match exactly one frontend user, and that user's active collection (Phase 3 resolver) is used. Zero or two matches return 422 with the PHP messages. `FormBeforeUpdate` rejects a cross-collection update with the `backend::lang.form.not_found` message, as PHP does. No collection picker in the SPA.
#### Relation manager and settings
- **D-15:** `config_relation.yaml` is parsed into a typed relation-manager schema (label, `view.list.columns`, `manage.list.columns`, `toolbarButtons`, `showSearch`) served at the relation schema endpoint. In `fields.yaml` the Collections `editors` entry becomes `type: relation-manager` with `relation: editors`, `tab`, `span: full`, `context: update`; `type: partial` does not exist in Go and is a boot error. Endpoints: `GET /{id}/relations/{name}` (linked, view columns), `GET /{id}/relations/{name}/candidates` (manage columns, search, `RelationExtendManageQuery` applied, so the owner is excluded), `POST .../link` and `POST .../unlink` with `{ids}`.
- **D-16:** Link writes the pivot through the Phase 5 join-table contract and calls `RelationBeforeLink` so the app stamps pivot columns; for editors that is `role='editor'`, `granted_at=now`, `granted_by=`. Unlink deletes the pivot row. The framework never hardcodes pivot column names.
- **D-17:** Settings pages are a framework capability: plugins implement `HasSettings` returning entries in the `registerSettings()` shape (code, label, description, category, icon, model, order, keywords, permissions). The model is a singleton-row GORM model with `Fillable()`, `Rules()` and a `models//fields.yaml`; the framework serves that form schema at `/settings/{code}/schema` and `GET`/`PUT /settings/{code}` reads and upserts the single row through `lagoon.Fill` and `lagoon.Validate`. Fonoteka binds its typed `golem15_fonoteka_settings` model (Phase 5) under code `fonoteka` gated by `golem15.fonoteka.manage_settings`.
- **D-18:** Navigation is declared through `HasNavigation` in the `registerNavigation()` shape (label, icon, permissions, order, `sideMenu`), except that `url` is replaced by `controller` holding the admin controller ID (`golem15.fonoteka.albums`); the SPA derives its route from that ID. `GET /navigation` returns only items the admin's permissions allow (D-03 wildcard rules), with labels resolved (D-07), plus a separate `settings` list built from `HasSettings` entries the admin may manage.
### the agent's Discretion
- Package name and internal layout of the admin API and schema packages, following the existing beach-themed naming.
- When YAML is parsed: recommended at boot with fail-loud errors naming plugin, controller and file, cached for the process lifetime.
- Whether `rules:` keys in `fields.yaml` are honoured in addition to the model's `Rules()`; at minimum `required: true` must produce a 422.
- Admin login throttling and password policy; reuse Phase 7's throttle shape if cheap, otherwise note as a Phase 10 or later hardening item.
- Whether soft-deleted rows appear in admin lists (recommended: hidden, matching Winter's default).
- Whether the settings row is created on first read or first write.
- Pagination of relation candidates (recommended: same list contract, D-11).
### Deferred Ideas (OUT OF SCOPE)
- Remaining Winter filter scope types (`checkbox`, `dropdown`, `group`, `text`, `number`, `numberrange`, `date`) are added when a ported plugin needs them; the schema types should leave room for them. Note this in the roadmap when the user or journal plugin ports are planned.
- `backend_user_groups` and Winter's user-preference and access-log tables: not needed by any Golem15 plugin yet.
- Policy-based row or field-level admin permissions (Directus style) remain a post-v1 differentiator per FEATURES.md.
- A boot-time admin seed from environment variables for containers, if deployment needs it later.
- Admin login throttling and password policy hardening if not folded in under Claude's discretion.
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| AUTH-08 | Backend admin users with roles and a permissions registry are separate from frontend users, and gate navigation and admin controller access | Separate audience-scoped guard, backend models/migrations, capability registry, middleware + per-handler authorization order. |
| ADMIN-01 | fields.yaml becomes a JSON form schema with the named field/layout features | Typed discriminated field structures, strict decoder, model option provider, phrasebook resolution. |
| ADMIN-02 | columns.yaml becomes a JSON list schema | Typed column structure, relation join allowlist, sortable/searchable field allowlists. |
| ADMIN-03 | Relation-manager schema replaces the Collections partial | Relation YAML parser plus linked/candidate/link/unlink operations using explicit pivot writes. |
| ADMIN-04 | CRUD hooks and lifecycle-safe bulk delete | Controller hook interfaces; scoped record loading; per-record `Delete` in a transaction. |
| ADMIN-05 | Settings model binds to the same schema pipeline | Existing singleton `Settings`, `Fillable`, `Rules`, YAML schema, permission-gated GET/PUT. |
## Summary
Build this as two deliberately separated subsystems joined only at a narrow controller registry: the framework owns backend principal/role persistence, an audience-bound `backend` guard, permission evaluation, schema compilation/caching, raw admin routes, and generic CRUD orchestration; Fonoteka owns the five controller declarations, embedded YAML, permissions/navigation/settings declarations, model factories, and collection/pivot hooks. This protects the reusable framework from Płytarium table names while preserving plugin extension points. [VERIFIED: 09-CONTEXT.md:9-13,85-109]
The most important implementation order is security first: extend the existing JWT primitive to mint and validate an expected audience, create a backend-specific user provider and blacklist usage, then ensure the raw group authenticates before resolving controller IDs and checks `RequiredPermissions()` before every schema or data operation. Resolve an admin controller only from the boot-built registry, never from a path-derived Go type or unvalidated YAML class name. [VERIFIED: bouncer/jwt.go:80-122; 09-CONTEXT.md:22-23,33-38] [CITED: https://github.com/golang-jwt/jwt/blob/main/MIGRATION_GUIDE.md]
**Primary recommendation:** Plan the phase in vertical foundations: (1) backend auth/roles and its security tests, (2) typed compile-at-boot schema registry and fixtures, (3) generic permission-gated API/CRUD engine, (4) Fonoteka controller/assets/hooks/settings ports, and (5) an independent security review plus assembled-route tests. [VERIFIED: 09-CONTEXT.md:9-13,88-109]
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|--------------|----------------|-----------|
| Admin login, refresh, logout and token revocation | API / Backend | Database / Storage | Credentials, JWT validation and token blacklist must be server-owned. [VERIFIED: 09-CONTEXT.md:22,33-34] |
| Roles and permissions | API / Backend | Database / Storage | A plugin declares permissions but the backend evaluates them before any controller action. [VERIFIED: 09-CONTEXT.md:23] |
| YAML compilation and schema localization | API / Backend | CDN / Static | Embedded plugin assets are parsed at boot and served as a cached JSON contract. [VERIFIED: 09-CONTEXT.md:27-30,48] |
| CRUD, query scopes and relation links | API / Backend | Database / Storage | Allowlisted query operations, GORM scopes, lifecycle hooks and pivot writes belong server-side. [VERIFIED: 09-CONTEXT.md:35-43] |
| Navigation and settings discovery | API / Backend | Browser / Client | Server filters entries by permission and resolves labels; Phase 10 only renders the returned contract. [VERIFIED: 09-CONTEXT.md:29,44] |
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `github.com/goccy/go-yaml` | `v1.19.2` | Strictly decode embedded Winter-shaped YAML | Already a direct project dependency; its decoder has `DisallowUnknownField`, which rejects keys not represented by exported struct fields. [VERIFIED: go.mod:10-19] [CITED: https://github.com/goccy/go-yaml] |
| `github.com/golang-jwt/jwt/v5` | `v5.3.1` | Existing HS256 JWT mint/verify plus required audience validation | Already used by `bouncer`; v5 exposes parser options for expected audience/issuer and permitted signing methods. [VERIFIED: go.mod:10-19; bouncer/jwt.go:129-160] [CITED: https://github.com/golang-jwt/jwt/blob/main/MIGRATION_GUIDE.md] |
| `gorm.io/gorm` | `v1.31.2` | Admin persistence, scoped reads, soft delete and transactions | Existing data layer already models collection editors and lifecycle callbacks with GORM. [VERIFIED: go.mod:28-30; ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go:13-46] [CITED: https://gorm.io/docs/associations.html] |
### Supporting
| Library / project primitive | Version | Purpose | When to Use |
|----------------------------|---------|---------|-------------|
| `bouncer` | in-repo | Guard registry, bcrypt, JWT mint/verify, blacklist | Extend, do not duplicate, for backend user lookup and audience validation. [VERIFIED: bouncer/registry.go:17-84; bouncer/password.go:1-25] |
| `lagoon.Fill` / `lagoon.Validate` | in-repo | Fillable allowlist and Laravel-rule validation | Every create/update/settings write; never bind JSON directly to a GORM model. [VERIFIED: lagoon/fill.go:13-18; lagoon/validate.go:22-40] |
| `phrasebook.Translator` | in-repo | Request-locale translation and raw-key fallback | Compile display-ready schema/navigation output. [VERIFIED: phrasebook/translator.go:91-126] |
| `surf.GroupRaw` | in-repo | Raw admin API group | Mount `/_admin/api/v1` without the Fonoteka house envelope. [VERIFIED: surf/router.go:149-169; 09-CONTEXT.md:33-34] |
**Installation:** none. All required Go packages are already direct dependencies; Phase 9 must not add a package. [VERIFIED: go.mod:1-30; 09-CONTEXT.md:22]
## Architecture Patterns
### System Architecture Diagram
```text
embedded plugin fs.FS
|
v
boot: controller registry -> strict YAML compiler -> cached typed schemas
| |-> phrasebook(locale) at request serialization
v
plugin capabilities: permissions / navigation / settings / admin controllers
|
v
raw /_admin/api/v1 routes -> backend guard -> audience + blacklist -> principal
| |
| v
+-> resolve controller from registry -> RequiredPermissions -> 403 envelope
|
+-----------------------------+-------------------------+
v v v
schema/nav list/form CRUD relation/settings
JSON contract Fill + Validate + hooks explicit pivot/upsert
|
v
GORM/Postgres
```
### Recommended Project Structure
```text
summercms.go/
├── bouncer/ # audience-aware JWT and backend principal support
├── pact/ # capability and hook interfaces
├── [beach-package]/ # typed schema compiler, registry, permissions, admin HTTP
└── internal/build/ # Winter-shaped admin-controller generator layout
fonoteka.go/plugins/golem15/fonoteka/
├── controllers/ # five AdminController declarations and hooks
├── controllers// # config_{form,list,filter,relation}.yaml
├── models// # fields.yaml and columns.yaml
└── plugin.go # HasPermissions, HasNavigation, HasSettings, controllers
```
### Pattern 1: Compile once, serialize per request
Parse only embedded, plugin-owned files at boot into typed intermediate structs and cache by `(pluginID, controllerID)`. Validate references then: model exists in the controller registry, typed field/column kind is supported, configured relation belongs to the registered model metadata, string `options` has a compatible provider, `conditions` is rejected, and the relation manager only names declared relations. At request time, copy/serialize the cached schema while translating label-like keys with the selected locale. This fulfills fail-loud assets without caching a locale-specific response. [VERIFIED: 09-CONTEXT.md:27-30,36,41,48]
```go
// Source: 09-CONTEXT.md D-06/D-08; goccy/go-yaml public API
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
if err := dec.Decode(&form); err != nil { return bootError(pluginID, controllerID, file, err) }
```
The goccy option's documented behavior is to reject input object keys that do not match non-ignored exported destination fields. [CITED: https://github.com/goccy/go-yaml] Do not use `map[string]any` as the primary representation, because it would defer unsupported YAML errors and weakens the JSON schema contract. [VERIFIED: 09-CONTEXT.md:28]
### Pattern 2: Controller registry is the authorization boundary
Collect `HasAdminControllers` during framework assembly into an immutable registry keyed by controller ID. Routes parse only constrained path segments, then resolve the ID in that registry; handlers never turn `{vendor}/{plugin}/{controller}` into a filesystem path, SQL identifier, reflection target, or arbitrary GORM model. Check backend authentication, then required permission(s), then object/scope hooks, in that order. [VERIFIED: pact/capabilities.go:100-112; 09-CONTEXT.md:23,33-38]
### Pattern 3: Write pipeline is explicit and transactional
For create/update: decode a JSON object, reject non-form/`Fillable` keys, merge generated `required: true` validation with model rules, validate, run the relevant pre-hook with the authenticated principal in context, then `lagoon.Fill` and persist. For bulk delete: first obtain each target through `ListExtendQuery`, then call GORM `Delete` separately for each row in one transaction. This preserves the existing `Collection.BeforeDelete` soft-delete cascade. [VERIFIED: lagoon/validate.go:22-40; ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go:40-45; 09-CONTEXT.md:37]
### Pattern 4: Relation operations use model metadata and an explicit pivot contract
Discover relation metadata from the registered model, not request-supplied table/column names. Linked and candidate list queries use the same finite set of searchable/sortable columns as normal lists. Link/unlink calls use a transaction and an explicit join model supplied by the plugin; call `RelationBeforeLink` before inserting so only the plugin determines pivot fields such as `role`, `granted_at`, and `granted_by`. [VERIFIED: lagoon/relations.go:9-25; ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go:22-23; 09-CONTEXT.md:41-43]
### Anti-Patterns to Avoid
- **One shared JWT configuration:** accepting user and admin tokens through the same verifier without an expected audience defeats the required identity separation. [VERIFIED: 09-CONTEXT.md:22] [CITED: https://github.com/golang-jwt/jwt/blob/main/MIGRATION_GUIDE.md]
- **Navigation-only authorization:** hidden navigation is UI convenience, not authorization; controller/schema/data endpoints must independently enforce permissions. [VERIFIED: 09-CONTEXT.md:23,44] [CITED: https://cornucopia.owasp.org/taxonomy/asvs-5.0/level-1-controls]
- **General SQL from `sort`, `select`, `conditions`, relation or model YAML:** bind values but choose identifiers only from compiled allowlists; raw `conditions:` is expressly rejected. [VERIFIED: 09-CONTEXT.md:35-36]
- **Direct JSON-to-model binding:** it admits mass assignment and bypasses `Fillable`, validation and hooks. [VERIFIED: lagoon/fill.go:13-18; 09-CONTEXT.md:37,43] [CITED: https://cornucopia.owasp.org/taxonomy/asvs-5.0/02-validation-and-business-logic/02-input-validation]
- **`DELETE ... WHERE id IN (...)`:** it bypasses per-record extension queries and lifecycle semantics required by D-13. [VERIFIED: 09-CONTEXT.md:37]
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Password hashing | custom digest/cost logic | `bouncer.HashPassword`, `CheckPassword`, `NeedsRehash` | Existing bcrypt helpers centralize malformed-hash handling and migration. [VERIFIED: bouncer/password.go:1-25] |
| JWT parsing | manual base64/signature/claim parsing | audience-enhanced `bouncer` using `jwt/v5` parser options | Existing code pins HS256 and requires expiration; add expected audience rather than a second parser. [VERIFIED: bouncer/jwt.go:129-160] [CITED: https://github.com/golang-jwt/jwt/blob/main/MIGRATION_GUIDE.md] |
| Validation / assignment | reflection binder | `lagoon.Validate` then `lagoon.Fill` with model `Rules`/`Fillable` | Existing code returns field-keyed validation and maintains assignment backstop. [VERIFIED: lagoon/validate.go:22-40; lagoon/fill.go:13-18] |
| Translation | client-side phrase-key catalogue | `phrasebook.Translator` | The locked API sends already-resolved display strings in the admin locale. [VERIFIED: phrasebook/translator.go:107-126; 09-CONTEXT.md:29] |
| M2M pivot writes | GORM association append/replace | Phase 5 explicit join-table transaction | Business pivot fields cannot be generic framework knowledge. [VERIFIED: lagoon/relations.go:9-25] [CITED: https://gorm.io/docs/associations.html] |
## Common Pitfalls
### Pitfall 1: Audience is minted but never verified
**What goes wrong:** A frontend JWT signed by a different secret policy or an admin JWT with any audience can cross the boundary.
**How to avoid:** Add `Audience` to the registered claims in `Mint` and `jwt.WithAudience(expected)` to every backend verification path; test user token against backend routes and backend token against user routes. `jwt/v5` exposes expected-audience parser validation. [VERIFIED: bouncer/mint.go:17-48; bouncer/jwt.go:149-160] [CITED: https://github.com/golang-jwt/jwt/blob/main/MIGRATION_GUIDE.md]
### Pitfall 2: Checks happen after schema lookup or query building
**What goes wrong:** Unauthorized callers can enumerate controller/schema existence, or a missing `RequiredPermissions` check exposes a write route.
**How to avoid:** Guard -> registry resolution -> permission -> handler, and cover every route class (navigation, schema, list, form, relation, bulk delete, settings) with a 401/403 matrix. [VERIFIED: 09-CONTEXT.md:23,33-44]
### Pitfall 3: Schema type strings leak into SQL/reflection
**What goes wrong:** YAML or query strings become identifiers, enabling accidental unsupported behavior or injection.
**How to avoid:** Discriminated Go types plus compiled finite allowlists for field, column, relation, filter and sort; reject `conditions:` and unknown keys at boot. [VERIFIED: 09-CONTEXT.md:28,35-36] [CITED: https://cornucopia.owasp.org/taxonomy/asvs-5.0/02-validation-and-business-logic/02-input-validation]
### Pitfall 4: Generic GORM association APIs erase pivot semantics
**What goes wrong:** Editors relation writes do not stamp plugin-owned columns or omit link hooks.
**How to avoid:** Require a relation adapter/join model and use the existing explicit pivot contract, transaction, and `RelationBeforeLink`. [VERIFIED: lagoon/relations.go:9-25; 09-CONTEXT.md:41-43]
### Pitfall 5: Bulk delete bypasses soft-delete cascade and row scope
**What goes wrong:** Cross-collection records can be deleted or lifecycle callbacks skipped.
**How to avoid:** Load every ID with `ListExtendQuery`; transactionally call `Delete` per model. Normal GORM queries exclude `gorm.DeletedAt` records. [VERIFIED: ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go:40-45; 09-CONTEXT.md:37] [CITED: https://gorm.io/docs/delete.html]
### Pitfall 6: Localized labels cached too early
**What goes wrong:** The first administrator's language becomes every administrator's schema language.
**How to avoid:** Cache source schema only; resolve label/comment/tab/option/navigation strings at serialization with request locale, falling back to app locale. [VERIFIED: phrasebook/translator.go:107-126; 09-CONTEXT.md:29,48]
## Code Examples
### Required permission middleware shape
```go
// Source: 09-CONTEXT.md D-03/D-10/D-13 (interface names and envelope codes locked there)
func requireControllerPermission(next http.Handler, ctl pact.AdminController) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
admin, ok := bouncer.User(r.Context())
if !ok { writeAdminError(w, "unauthenticated", http.StatusUnauthorized); return }
if !permissions.Allows(admin, ctl.RequiredPermissions()) {
writeAdminError(w, "forbidden", http.StatusForbidden); return
}
next.ServeHTTP(w, r)
})
}
```
The example is ordering guidance, not a new API signature: preserve the locked error codes and make the actual principal/permission interfaces fit existing `bouncer.Principal` conventions. [VERIFIED: 09-CONTEXT.md:23,34,37]
### Strict YAML boundary
```go
// Source: goccy/go-yaml decoder API; D-06 requires this option.
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
if err := dec.Decode(&schema); err != nil {
return fmt.Errorf("admin schema %s/%s/%s: %w", pluginID, controllerID, file, err)
}
```
[CITED: https://github.com/goccy/go-yaml] [VERIFIED: 09-CONTEXT.md:28,48]
## State of the Art
| Old approach | Current approach | Impact |
|--------------|------------------|--------|
| `pact.AdminController` only exposes ID, model name and config directory | Phase 9 makes it the basis for compiled schema, permissions and generic operations | Keep the existing three-method interface compatible while adding optional capability interfaces. [VERIFIED: pact/capabilities.go:100-112; 09-CONTEXT.md:23,37] |
| `type: partial` provides Collections editors UI | Typed relation-manager schema plus JSON relation endpoints | Phase 10 can render a first-class link/unlink workflow without HTML partial execution. [VERIFIED: 09-CONTEXT.md:41] |
| User JWT guard validates HS256, expiration and subject | Backend guard additionally validates a distinct expected audience | Cross-role token confusion is rejected at the shared primitive. [VERIFIED: bouncer/jwt.go:129-160; 09-CONTEXT.md:22] |
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Admin-login throttling is an executable planning rule: reuse/extract the Phase 7 throttle when low-cost; otherwise make the omission a named security-review finding. | Security / Open Questions (RESOLVED) | The plan cannot silently omit hardening. |
| A2 | Create the singleton settings row on first write so GET remains side-effect-free. | Open Questions (RESOLVED) | A caller requiring a persisted row on GET would need a future explicit change. |
## Open Questions (RESOLVED)
1. **How should field-level `rules:` compose with model `Rules()`?**
- What we know: model `Rules()` and `lagoon.Validate` are the existing backstop; D-06 requires YAML `required: true` to yield 422 at minimum. [VERIFIED: lagoon/validate.go:22-40; 09-CONTEXT.md:49]
- **RESOLVED:** Parse only YAML `required` in Phase 9 and merge it additively with model `Rules()`; YAML must never relax or replace a model rule. Defer richer YAML rule grammar until a ported plugin needs it. [VERIFIED: 09-CONTEXT.md:49]
2. **Should admin login throttling ship here?**
- What we know: it is discretionary; OWASP ASVS calls for documented anti-automation controls and successful/failed auth event logging. [VERIFIED: 09-CONTEXT.md:50] [CITED: https://cornucopia.owasp.org/taxonomy/asvs-5.0/06-authentication/01-authentication-documentation] [CITED: https://cornucopia.owasp.org/taxonomy/asvs-5.0/16-security-logging-and-error-handling/03-security-events]
- **RESOLVED:** The auth-foundation plan must reuse/extract the Phase 7 throttle when doing so is low-cost. If that extraction is not low-cost, the security-review plan must contain a named finding for admin-login throttling; it cannot be silently omitted. [VERIFIED: 09-CONTEXT.md:50]
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|-------------|-----------|---------|----------|
| Go | framework/app compile and test | ✓ | `go1.27.0-X:nodwarf5` | — |
| Docker | existing Testcontainers integration tests | ✓ | `29.7.2` | — |
| PostgreSQL CLI | schema/migration diagnostics | ✓ | `psql 18.6` | — |
| Local PostgreSQL service | ad-hoc live DB verification | ✗ | no response on `/run/postgresql:5432` | Testcontainers Postgres |
| `swag` CLI | regenerated OpenAPI artifact | ✗ | — | existing project generator path / install only if planner schedules regeneration |
**Missing dependencies with no fallback:** none. [VERIFIED: environment probes, 2026-09-24]
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go standard `testing`; project uses Testcontainers Postgres for integration tests. [VERIFIED: go.mod:20-21; .planning/REQUIREMENTS.md:121] |
| Config file | none — package-local `*_test.go` convention. [VERIFIED: repository file inventory, 2026-09-24] |
| Quick run command | `go test ./bouncer ./pact ./lagoon ./party ./surf` |
| Full suite command | run `go test ./...` in both `summercms.go` and `fonoteka.go` workspaces; use the existing real-Postgres test harness for migration/CRUD behavior. [VERIFIED: .planning/REQUIREMENTS.md:121; ../fonoteka.go/go.work:1-8] |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| AUTH-08 | backend/user token audience separation; role, wildcard, superuser and every protected route | unit + assembled integration | `go test ./bouncer ./[admin-package] ./...` | ❌ Wave 0 |
| ADMIN-01 | valid YAML compiles; unknown key/type/options provider fails at boot; localized schema output | unit + integration | `go test ./[schema-package] ./...` | ❌ Wave 0 |
| ADMIN-02 | list schema and search/sort/relation-column allowlists | unit + integration | `go test ./[admin-package] ./...` | ❌ Wave 0 |
| ADMIN-03 | relation schema; linked/candidates/link/unlink; pivot hook and owner exclusion | integration | `go test ./plugins/golem15/fonoteka/...` | ❌ Wave 0 |
| ADMIN-04 | hooks order, row scope and per-record lifecycle bulk delete | integration | `go test ./plugins/golem15/fonoteka/...` | ❌ Wave 0 |
| ADMIN-05 | settings schema and Fill/Validate permission-gated singleton read/write | integration | `go test ./plugins/golem15/fonoteka/...` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** affected package tests plus `go vet ./...` in its workspace. [VERIFIED: .planning/REQUIREMENTS.md:121]
- **Per wave merge:** `go test ./...` in both repositories/workspaces.
- **Phase gate:** full suites green plus independently executed security-review cases for guard/audience, permission ordering and mass assignment. [VERIFIED: 09-CONTEXT.md:13]
### Wave 0 Gaps
- [ ] Add framework schema compiler tests with golden JSON and malformed-YAML fixtures.
- [ ] Add backend guard tests for empty secret, wrong audience, user-vs-backend token swapping, blacklist and inactive/deleted users.
- [ ] Add assembled raw-route authorization matrix for every admin endpoint category.
- [ ] Add real-Postgres tests for bulk-delete callbacks, relation pivot fields, relation candidate scoping, and settings upsert.
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Validation and Business Logic | yes | strict YAML schemas; request/query allowlists; `lagoon.Validate`; no raw `conditions:`. [CITED: https://cornucopia.owasp.org/taxonomy/asvs-5.0/02-validation-and-business-logic/02-input-validation] |
| V4 API and Web Service | yes | uniform raw admin envelope, bounded request/query parsing and authenticated endpoints. [CITED: https://cornucopia.owasp.org/taxonomy/asvs-5.0] |
| V6 Authentication | yes | bcrypt, distinct admin secret, opaque login failure, throttle decision, command-only first-admin creation. [VERIFIED: bouncer/password.go:1-25; 09-CONTEXT.md:21-24] |
| V7 Session Management | yes | short-lived JWTs, refresh/revocation blacklist and fail-closed validation. [VERIFIED: bouncer/jwt.go:87-122; 09-CONTEXT.md:22] |
| V8 Authorization | yes | server-side function and object/scoped checks before every admin operation. [CITED: https://cornucopia.owasp.org/taxonomy/asvs-5.0/level-1-controls] |
| V9 Self-contained Tokens | yes | HS256 allowlist, required expiration, expected audience and per-guard secret. [VERIFIED: bouncer/jwt.go:129-160; 09-CONTEXT.md:22] [CITED: https://github.com/golang-jwt/jwt/blob/main/MIGRATION_GUIDE.md] |
| V16 Security Logging and Error Handling | yes | log successful/failed backend authentication and denied authorization without passwords/JWTs; expose the fixed error envelope only. [CITED: https://cornucopia.owasp.org/taxonomy/asvs-5.0/16-security-logging-and-error-handling/03-security-events] |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Frontend JWT accepted as backend JWT | Spoofing / Elevation | Separate secrets plus `aud` mint and verification; regression test both crossovers. [VERIFIED: 09-CONTEXT.md:22] |
| Permission absent on a new route | Elevation | Registry-level authorization wrapper and full endpoint matrix, not navigation hiding. [VERIFIED: 09-CONTEXT.md:23,44] |
| Mass assignment of role, owner, IDs or hidden attributes | Tampering / Elevation | `Fillable` allowlist; schema field allowlist; validation before persistence. [VERIFIED: lagoon/fill.go:13-18; ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go:31-38] |
| Sort/filter/relation identifier injection | Tampering | Compile finite identifiers from typed YAML; parameterize values; reject `conditions:`. [VERIFIED: 09-CONTEXT.md:35-36] |
| Cross-collection record access | Information Disclosure / Elevation | Apply `ListExtendQuery`/`FormExtendQuery` before every read/write and re-check in relation operations. [VERIFIED: 09-CONTEXT.md:37-38,41] |
| Pivot privilege/data forgery | Tampering | Plugin-owned join model and `RelationBeforeLink`; framework does not hardcode columns. [VERIFIED: 09-CONTEXT.md:42] |
## Sources
### Primary
- [goccy/go-yaml README](https://github.com/goccy/go-yaml) — decoder/tag behavior and the project-selected YAML implementation.
- [golang-jwt/jwt v5 migration guide](https://github.com/golang-jwt/jwt/blob/main/MIGRATION_GUIDE.md) — parser audience/issuer and signing-method validation options.
- [GORM associations documentation](https://gorm.io/docs/associations.html) and [GORM delete documentation](https://gorm.io/docs/delete.html) — association and soft-delete behavior.
- [OWASP ASVS 5.0 taxonomy](https://cornucopia.owasp.org/taxonomy/asvs-5.0) — relevant security control categories.
### In-repository
- `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md` — locked phase scope and acceptance contract.
- `bouncer/jwt.go`, `bouncer/mint.go`, `bouncer/registry.go`, `bouncer/password.go` — reusable guard/JWT/bcrypt seams.
- `pact/capabilities.go`, `lagoon/fill.go`, `lagoon/validate.go`, `lagoon/relations.go`, `phrasebook/translator.go` — capability and data safety patterns.
- `../fonoteka.go/plugins/golem15/{user,fonoteka}` — actual plugin boot, user auth, models, routes and test layout.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — packages are already direct dependencies and their primary documentation was checked. [VERIFIED: go.mod:10-30]
- Architecture: HIGH — locked phase decisions and relevant source seams were read. [VERIFIED: 09-CONTEXT.md:17-53; bouncer/jwt.go:80-160; pact/capabilities.go:100-112]
- Pitfalls/security: MEDIUM — supported by primary library/OWASP documentation and code inspection; final enforcement needs the required independent security review. [VERIFIED: 09-CONTEXT.md:13]
**Research date:** 2026-09-24
**Valid until:** 2026-10-24 (stable Go libraries; implementation constraints are locked in CONTEXT.md).