fix(10.1): WR-05 drop widgets the admin may not run from the form schema
formSchema now filters type: widget fields by the action's permissions, the same D-12 filtering listSchema applies to toolbarActions, so an admin without the action permission no longer gets a button that always answers 403, and the action name is not revealed. The filtered fields are a new slice, so the cached schema is never modified.
This commit is contained in:
@@ -14,7 +14,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
|||||||
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
||||||
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
||||||
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
|
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
|
||||||
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
||||||
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
||||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
||||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||||
|
|||||||
@@ -519,6 +519,21 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
|||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Like toolbarActions in the list schema (D-12), a widget whose
|
||||||
|
// action this admin may not run is not offered. A new slice: the
|
||||||
|
// localized view must never share its backing array with the cache.
|
||||||
|
principal, _ := bouncer.User(r.Context())
|
||||||
|
kept := make([]FormField, 0, len(view.Fields))
|
||||||
|
for _, field := range view.Fields {
|
||||||
|
if field.Type == "widget" {
|
||||||
|
action, ok := cc.Actions[field.Action]
|
||||||
|
if !ok || !Allows(principal, action.Permissions) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept = append(kept, field)
|
||||||
|
}
|
||||||
|
view.Fields = kept
|
||||||
view.Assets = s.controllerAssets(cc)
|
view.Assets = s.controllerAssets(cc)
|
||||||
meta := map[string]any{}
|
meta := map[string]any{}
|
||||||
if view.Meta.Locale != "" {
|
if view.Meta.Locale != "" {
|
||||||
|
|||||||
@@ -435,6 +435,33 @@ func TestPhase101Actions(t *testing.T) {
|
|||||||
t.Fatalf("limited toolbarActions = %+v err=%v", list.Data.ToolbarActions, err)
|
t.Fatalf("limited toolbarActions = %+v err=%v", list.Data.ToolbarActions, err)
|
||||||
}
|
}
|
||||||
env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/partials/stats", "", "limited")
|
env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/partials/stats", "", "limited")
|
||||||
|
// WR-05: the form schema offers no widget whose action the admin
|
||||||
|
// cannot run, and keeps every other field; an admin who may run it
|
||||||
|
// still gets the widget.
|
||||||
|
widgets := func(token string) (widgets, others []string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets/schema/form", "", token)
|
||||||
|
var form cabana.Envelope[cabana.FormView]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &form); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, field := range form.Data.Fields {
|
||||||
|
if field.Type == "widget" {
|
||||||
|
widgets = append(widgets, field.Name+":"+field.Action)
|
||||||
|
} else {
|
||||||
|
others = append(others, field.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return widgets, others
|
||||||
|
}
|
||||||
|
limitedWidgets, limitedOthers := widgets("limited")
|
||||||
|
fullWidgets, fullOthers := widgets("bearer")
|
||||||
|
if len(limitedWidgets) != 0 || strings.Contains(strings.Join(limitedOthers, ","), "lookup") {
|
||||||
|
t.Fatalf("limited form widgets = %v", limitedWidgets)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(fullWidgets, []string{"lookup:lookup"}) || !reflect.DeepEqual(limitedOthers, fullOthers) {
|
||||||
|
t.Fatalf("full widgets = %v, others limited %v full %v", fullWidgets, limitedOthers, fullOthers)
|
||||||
|
}
|
||||||
if calls := env.spy.take(); len(calls) != 0 {
|
if calls := env.spy.take(); len(calls) != 0 {
|
||||||
t.Fatalf("action ran for a denied admin: %+v", calls)
|
t.Fatalf("action ran for a denied admin: %+v", calls)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user