Files
summercms/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-RESEARCH.md
2026-09-24 15:57:19 +02:00

39 KiB

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>

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 <login|email>. 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/<name>/config_form.yaml and config_list.yaml name the model and point at models/<model>/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/<name>/ 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: <methodName> 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[<scope>]=.... 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=<admin id>. 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/<name>/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. </user_constraints>

<phase_requirements>

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.
</phase_requirements>

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

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
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/<name>/       # config_{form,list,filter,relation}.yaml
├── models/<name>/            # 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]

// 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

// 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

// 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?

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

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).