Files
summercms/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md
2026-09-23 10:24:22 +02:00

18 KiB

Phase 9: Backend admin authentication and schema pipeline - Context

Gathered: 2026-09-23 Status: Ready for planning

## Phase Boundary

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

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

<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 — the HasAdminControls/HasNavigation/HasPermissions sketch this phase makes real (its scs cookie-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.yaml and columns.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.yaml and /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's backend_users and backend_user_roles migrations, 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): the backend guard registers here under a new name with its own UserProvider.
  • 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.AdminController and HasAdminControllers (pact/capabilities.go:100-112): the only admin surface today; HasNavigation and HasPermissions exist as a comment at lines 140-142 and become real interfaces here.
  • internal/build/artifact.go:230-304 and internal/build/stubs/artifacts.tmpl: the make:admin-controller generator 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.
  • phrasebook per-locale bundles (Phase 4): label resolution for D-07.
  • fonoteka.go/scripts/swagger2openapi.go and docs/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: the Editors many2many 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 in fonoteka.go).
  • Parameterized middleware and raw groups in surf (Phase 6 D-05, D-16): the admin group is a raw group under the backend guard.

Integration Points

  • party.Activate / surf assembly: where HasPermissions, HasNavigation, HasSettings and HasAdminControllers are collected and the admin routes mounted.
  • bouncer.Registry shared through backpack.App Lookup/Publish as the user plugin does in plugins/golem15/user/plugin.go:64-97.
  • lagoon migrations: 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.
## Deferred Ideas
  • 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: 09-backend-admin-authentication-and-schema-pipeline Context gathered: 2026-09-23