18 KiB
Phase 9: Backend admin authentication and schema pipeline - Context
Gathered: 2026-09-23 Status: Ready for planning
## Phase BoundaryPhase 9 delivers two things. First, a backend admin identity separate from frontend users: backend_users with roles, a permissions registry declared by plugins, an admin login on a second bouncer guard, and gating of both navigation and admin controller access. Second, the schema pipeline: each admin controller's config_form.yaml, config_list.yaml, config_filter.yaml and config_relation.yaml (pointing at the model's fields.yaml and columns.yaml) are parsed with goccy/go-yaml into typed JSON form, list, filter and relation-manager schemas served over a new framework-wide admin API, together with admin CRUD endpoints that expose the Winter extension hooks, bulk delete through lifecycle hooks, and a settings screen bound to a singleton settings model.
Framework code (guard, tables, permissions, navigation, schema parsers, admin API package, admin:* commands, scaffold layout change) lands in summercms.go. The five Płytarium controllers (Albums, Artists, Collections, Genres, Styles), their YAML, HasPermissions, HasNavigation, HasSettings and the Albums scoping hooks land in fonoteka.go. The Vue SPA that renders these schemas is Phase 10 and is out of scope here; this phase's acceptance is the JSON contract plus tests.
Requirements: AUTH-08, ADMIN-01, ADMIN-02, ADMIN-03, ADMIN-04, ADMIN-05. Security-load-bearing: apply the security-review agent to the admin guard, permission enforcement and mass-assignment on admin saves.
## Implementation DecisionsAdmin 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.
Claude'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).
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Roadmap and requirements
.planning/ROADMAP.md§Phase 9 — goal, success criteria, repos..planning/REQUIREMENTS.md— AUTH-08, ADMIN-01 through ADMIN-05..planning/research/ARCHITECTURE.md§Admin Form Save Flow and the capability-interface listing — theHasAdminControls/HasNavigation/HasPermissionssketch this phase makes real (itsscscookie-session suggestion is superseded by D-02)..planning/research/FEATURES.md§Admin / forms — field and column feature inventory..planning/research/STACK.md— goccy/go-yaml, swag v1, validator.
Prior phase decisions this phase builds on
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md— D-06 guard registry, D-16 raw groups, D-17 response conventions stay app-side..planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md— D-06 to D-10 JWT lifecycle and blacklist, D-19 bcrypt..planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md— Fillable/Hidden discipline, lagoon.Fill and lagoon.Validate, join-table contract, typed Settings table.
PHP reference (fonoteka, read-only)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php—registerPermissions(),registerSettings(),registerNavigation()bodies to port./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/{albums,artists,collections,genres,styles}/—config_form.yaml,config_list.yaml, Collections'config_relation.yaml, and the controller PHP with the hooks./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/{album,artist,collection,genre,style,settings}/fields.yamlandcolumns.yaml— the YAML the pipeline must parse./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php§getFormatOptions — the one model-method dropdown./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/BackendAlbumCollectionResolver.php— email mapping for D-14./media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/users/config_filter.yamland/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/posts/config_filter.yaml— the three filter scope shapes D-12 ports./media/nvme/dev/golem15/fonoteka/modules/backend/database/migrations/— Winter'sbackend_usersandbackend_user_rolesmigrations, the column source for D-01.
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
bouncer.Registry,Guard,CredentialGuard,UnauthorizedWriter(bouncer/registry.go,bouncer/guard.go): thebackendguard registers here under a new name with its ownUserProvider.bouncer.NewJWTGuard,Mint,VerifyClaims,NewPostgresBlacklist,HashPassword,CheckPassword(bouncer/jwt.go,mint.go,password.go): admin auth reuses them; the guard constructor may need an audience parameter.pact.AdminControllerandHasAdminControllers(pact/capabilities.go:100-112): the only admin surface today;HasNavigationandHasPermissionsexist as a comment at lines 140-142 and become real interfaces here.internal/build/artifact.go:230-304andinternal/build/stubs/artifacts.tmpl: themake:admin-controllergenerator whose layout D-05 changes.lagoon.Fill,lagoon.Validate,lagoon.Paginate,RegisterJoinTable(Phase 5): admin saves, 422s, list meta and pivot writes go through these.phrasebookper-locale bundles (Phase 4): label resolution for D-07.fonoteka.go/scripts/swagger2openapi.goanddocs/openapi.json: the swag v1 to OpenAPI 3 path the admin annotations feed.fonoteka.go/plugins/golem15/fonoteka/models/settings.go: the typed singleton D-17 binds.fonoteka.go/plugins/golem15/fonoteka/models/collection.go:22: theEditorsmany2many already declared.
Established Patterns
- Optional capability interfaces type-asserted by the consumer package (pact); D-13, D-17, D-18 follow it.
- Fail-loud boot on missing secrets and malformed plugin assets (Phase 3 secret, Phase 4 mail catalog validation); D-02, D-06, D-08, D-12 follow it.
- Framework stays app-agnostic: no Fonoteka table names or pivot columns in
summercms.go(D-16, D-14 resolver lives infonoteka.go). - Parameterized middleware and raw groups in surf (Phase 6 D-05, D-16): the admin group is a raw group under the
backendguard.
Integration Points
party.Activate/ surf assembly: whereHasPermissions,HasNavigation,HasSettingsandHasAdminControllersare collected and the admin routes mounted.bouncer.Registryshared throughbackpack.AppLookup/Publish as the user plugin does inplugins/golem15/user/plugin.go:64-97.lagoonmigrations: two new framework-owned tables (backend_users,backend_user_roles) plus their seed of system roles.- Phase 10 SPA consumes
/navigation, the schema endpoints and the list/form/relation endpoints exactly as specified in D-09 to D-11.
</code_context>
## Specific Ideas- The user explicitly wants this phase to be "our chance to introduce a nice and consistent API layer": the admin API contract in D-09 to D-11 is a framework convention meant to outlive Płytarium, not a one-off for five controllers.
- Filters must work the Winter way (
config_filter.yaml), even though Płytarium has none; the user and journal plugins are the reference consumers. - Model scope methods instead of SQL strings in YAML were accepted as a deliberate deviation from Winter.
- 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.
Phase: 09-backend-admin-authentication-and-schema-pipeline Context gathered: 2026-09-23