From 20a79c5df4875977270016769bbe3567215b7169 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 1 Oct 2026 21:13:40 +0200 Subject: [PATCH] fix(09): WR-09 scaffold admin controllers with a required permission and a record source placeholder --- docs/backend/admin-controllers.md | 2 +- docs/console/scaffolding.md | 2 +- internal/build/artifact.go | 2 ++ internal/build/build_test.go | 6 ++++++ internal/build/stubs/artifacts.tmpl | 19 +++++++++++++++++++ 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/backend/admin-controllers.md b/docs/backend/admin-controllers.md index 763a42a..fd744bd 100644 --- a/docs/backend/admin-controllers.md +++ b/docs/backend/admin-controllers.md @@ -154,7 +154,7 @@ func (p *BlogPlugin) Settings() []pact.SettingsItem { summer make:admin-controller acme.blog Posts ``` -The controller ID maps to the admin API path: `acme.blog.posts` is served under `/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md). +The generated controller requires the permission `acme.blog.access_posts`, so declare it in the plugin's `pact.HasPermissions` or the admin API refuses to start with an unknown-permission error. Its `NewRecord` returns `nil` until you return the model, and the list and every write answer 500 until then. The controller ID maps to the admin API path: `acme.blog.posts` is served under `/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md). ## Compilation at boot diff --git a/docs/console/scaffolding.md b/docs/console/scaffolding.md index 27eeb68..9be5718 100644 --- a/docs/console/scaffolding.md +++ b/docs/console/scaffolding.md @@ -58,7 +58,7 @@ summer make:model Comment | `summer make:migration` | `updates/_.go` | An empty gormigrate migration with up and down steps to fill in. | | `summer make:command` | `console/.go` | A `bonfire.Command` named `:`, such as `blog:publish`. | | `summer make:job` | `jobs/.go` | A typed job built with `conga.Job`; the plugin never imports the queue library. | -| `summer make:admin-controller` | `controllers/.go`, `controllers//config_form.yaml`, `controllers//config_list.yaml`, `models//fields.yaml`, `models//columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration. | +| `summer make:admin-controller` | `controllers/.go`, `controllers//config_form.yaml`, `controllers//config_list.yaml`, `models//fields.yaml`, `models//columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration. It already requires the permission `.access_`, which you declare in the plugin's `Permissions()`, and its `NewRecord` returns `nil` until you return your model, so the screens stay closed until you finish it. | File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit timestamp, so they sort in the order you created them. A second migration with the same name in the same second gets the next second's timestamp, but migrations with different names created in the same second share one timestamp and sort by name; check the order in `updates/`, as [Porting a plugin](../setup/porting-a-plugin.md) describes. diff --git a/internal/build/artifact.go b/internal/build/artifact.go index d78596a..6420f16 100644 --- a/internal/build/artifact.go +++ b/internal/build/artifact.go @@ -34,6 +34,7 @@ type artifactData struct { DownSQL string CommandName string Description string + Permission string } var migrationNow = func() time.Time { return time.Now().UTC() } @@ -265,6 +266,7 @@ func MakeAdminController(ctx context.Context, startDir, pluginID, name string) ( ConfigDir: configDir, PluginPath: strings.ReplaceAll(plugin.ID, ".", "/"), Snake: snake, + Permission: plugin.ID + ".access_" + snake, } src, err := renderStub("admin_controller.go", data) if err != nil { diff --git a/internal/build/build_test.go b/internal/build/build_test.go index e1a1e9c..d8db27e 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -769,6 +769,12 @@ func TestScaffoldAllArtifacts(t *testing.T) { `ID() string { return "golem15.demo.albums" }`, `ModelName() string { return "Albums" }`, `ConfigDir() string { return "controllers/albums" }`, + // WR-09: the scaffold is never open to every administrator and names + // its record source, so completing it cannot silently expose the data. + `pact.AdminPermissioned = albumsAdmin{}`, + `RequiredPermissions() []string`, + `return []string{"golem15.demo.access_albums"}`, + `NewRecord() any { return nil }`, } { if !bytes.Contains(adminSrc, []byte(want)) { t.Fatalf("albums.go missing %s:\n%s", want, adminSrc) diff --git a/internal/build/stubs/artifacts.tmpl b/internal/build/stubs/artifacts.tmpl index 7934486..817e838 100644 --- a/internal/build/stubs/artifacts.tmpl +++ b/internal/build/stubs/artifacts.tmpl @@ -90,12 +90,31 @@ package controllers import "git.golem15.com/golem15/summercms/modules/pact" +var ( + _ pact.AdminController = {{.Worker}}{} + _ pact.AdminRecordSource = {{.Worker}}{} + _ pact.AdminPermissioned = {{.Worker}}{} +) + type {{.Worker}} struct{} func ({{.Worker}}) ID() string { return {{printf "%q" .Kind}} } func ({{.Worker}}) ModelName() string { return {{printf "%q" .Ident}} } func ({{.Worker}}) ConfigDir() string { return {{printf "%q" .ConfigDir}} } +// NewRecord returns the model the generic admin handlers query and fill. +// TODO: return a pointer to the plugin's model, such as &models.{{.Ident}}{}. +// Until then the list and every write answer 500, so nothing is exposed. +func ({{.Worker}}) NewRecord() any { return nil } + +// RequiredPermissions is checked before any schema or record is served. Keep +// it: a controller without one is open to every administrator. Declare the +// code in the plugin's Permissions() (pact.HasPermissions), or the admin API +// refuses to start with an unknown-permission error that names this controller. +func ({{.Worker}}) RequiredPermissions() []string { + return []string{ {{printf "%q" .Permission}} } +} + // {{.Func}} returns the {{.Kind}} admin controller. func {{.Func}}() pact.AdminController { return {{.Worker}}{} } {{end}}