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) andbackend_user_roles(name, code, description, permissions JSON, is_system). Table and column names match Winter so an existing WinterCMSbackend_userstable can be copied straight in at cutover.backend_user_groupsis not ported. Role codesdeveloperandpublisherare seeded as system roles, matchingUserRole::CODE_DEVELOPER/CODE_PUBLISHER. - D-02: Admin sessions are JWTs issued by a second named guard,
backend, registered in the Phase 6 bouncer registry besidejwt. 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/scsis not adopted). - D-03: Permissions keep Winter semantics. Plugins implement
HasPermissionsreturning entries in theregisterPermissions()shape (code, tab, label, roles). Checks:is_superuserpasses everything; otherwise the admin's rolepermissionsJSON is consulted; a code ending in.*matches by prefix, sogolem15.fonoteka.*on the top navigation item works unchanged. An admin controller declaresRequiredPermissions() []stringand 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], plussummer 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.yamlandconfig_list.yamlname the model and point atmodels/<model>/fields.yamlandcolumns.yaml;config_filter.yamlandconfig_relation.yamlsit beside them when present. The five Płytarium YAML sets are copied across unchanged apart from the onepartialfield (D-15).summer make:admin-controlleris changed to emit this layout, andpact.AdminController.ConfigDir()points at thecontrollers/<name>/directory; all config is embedded through the plugin'sfs.FS. - D-06: The JSON schema is typed: Go structs per field type and per column type, decoded with goccy/go-yaml
DisallowUnknownFieldso 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,
emptyOptionand navigation labels are resolved server-side through phrasebook in the admin's locale (Accept-Language, thenapp.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 inmeta. - D-08:
options: <methodName>on a dropdown maps to an options-provider interface on the model:DropdownOptions(field string) []OptionwhereOptionis value plus label key (resolved per D-07). The pipeline calls it for any dropdown whoseoptions:is a string and fails at boot if the model does not implement the interface. A YAMLoptions:map is also accepted and served as-is. Album portsgetFormatOptionsthis 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:unauthenticated401,forbidden403,not_found404,validation_failed422 (details is field to messages),conflict409. The group is mounted raw-style under thebackendguard, so no fonoteka house middleware applies. - D-11: One list contract for controller lists and relation-manager lists alike: query
search(acrosssearchablecolumns),sortanddir(onlysortablecolumns, otherwise 422),page,per_page(defaultrecordsPerPage, capped), andfilter[<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 byfilter:inconfig_list.yaml. Three scope shapes are ported now, because those are the shapes the Golem15 user and journal plugins use:switchwith two conditions on a named column,daterangeon a named column, and a model-backed scope (modelClass,nameFrom,scope). Raw SQLconditions:strings are not supported: a scope either names a model scope method (scope: filterByGroupresolved through aFilterScope(name string, db *gorm.DB, value any) *gorm.DBinterface on the model) or uses the built-in switch/daterange handling on a declared column. A YAMLconditions: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-deletewith{ids}) loads each record throughListExtendQueryand deletes it individually so Phase 5 lifecycle hooks and soft-delete cascades fire; it never issues a singleDELETE ... WHERE id IN. - D-14: The Albums scoping hooks resolve the admin's active collection exactly as the PHP
BackendAlbumCollectionResolverdoes: 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.FormBeforeUpdaterejects a cross-collection update with thebackend::lang.form.not_foundmessage, as PHP does. No collection picker in the SPA.
Relation manager and settings
- D-15:
config_relation.yamlis parsed into a typed relation-manager schema (label,view.list.columns,manage.list.columns,toolbarButtons,showSearch) served at the relation schema endpoint. Infields.yamlthe Collectionseditorsentry becomestype: relation-managerwithrelation: editors,tab,span: full,context: update;type: partialdoes 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,RelationExtendManageQueryapplied, so the owner is excluded),POST .../linkandPOST .../unlinkwith{ids}. - D-16: Link writes the pivot through the Phase 5 join-table contract and calls
RelationBeforeLinkso the app stamps pivot columns; for editors that isrole='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
HasSettingsreturning entries in theregisterSettings()shape (code, label, description, category, icon, model, order, keywords, permissions). The model is a singleton-row GORM model withFillable(),Rules()and amodels/<name>/fields.yaml; the framework serves that form schema at/settings/{code}/schemaandGET/PUT /settings/{code}reads and upserts the single row throughlagoon.Fillandlagoon.Validate. Fonoteka binds its typedgolem15_fonoteka_settingsmodel (Phase 5) under codefonotekagated bygolem15.fonoteka.manage_settings. - D-18: Navigation is declared through
HasNavigationin theregisterNavigation()shape (label, icon, permissions, order,sideMenu), except thaturlis replaced bycontrollerholding the admin controller ID (golem15.fonoteka.albums); the SPA derives its route from that ID.GET /navigationreturns only items the admin's permissions allow (D-03 wildcard rules), with labels resolved (D-07), plus a separatesettingslist built fromHasSettingsentries 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 infields.yamlare honoured in addition to the model'sRules(); at minimumrequired: truemust 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_groupsand 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
Recommended Project Structure
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; rawconditions: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)
- How should field-level
rules:compose with modelRules()?- What we know: model
Rules()andlagoon.Validateare the existing backstop; D-06 requires YAMLrequired: trueto yield 422 at minimum. [VERIFIED: lagoon/validate.go:22-40; 09-CONTEXT.md:49] - RESOLVED: Parse only YAML
requiredin Phase 9 and merge it additively with modelRules(); 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]
- What we know: model
- 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 — decoder/tag behavior and the project-selected YAML implementation.
- golang-jwt/jwt v5 migration guide — parser audience/issuer and signing-method validation options.
- GORM associations documentation and GORM delete documentation — association and soft-delete behavior.
- OWASP ASVS 5.0 taxonomy — 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).