feat(quick-261016-s8x): pact.NavigationItem.Visible hides navigation entries at request time

- pact.NavigationItem gains Visible func(ctx) bool (json:"-"); nil keeps the
  entry always listed
- cabana.Registry.Metadata runs it after the permission check, drops a main
  item (with its side menu) or a side item that answers false, and relinks a
  parent whose controller is a hidden child's to its first visible openable
  child
- TestNavigationHidesInvisibleItemsAndRepointsTarget covers the rules
- pact and cabana READMEs, users-and-permissions and admin-controllers docs
  describe the hook
This commit is contained in:
Jakub Zych
2026-10-16 20:34:26 +02:00
parent 90b2d4e23b
commit 06e2e60ce1
7 changed files with 113 additions and 14 deletions

View File

@@ -158,7 +158,7 @@ The generated controller requires the permission `acme.blog.access_posts`, so de
## Compilation at boot
At start-up `cabana.Activate` compiles every plugin's controllers, settings, navigation and permissions once. Any schema mistake stops the start-up with an error that names the plugin, the controller and the file: an unknown YAML key, a `modelClass` that does not match `pact.AdminController.ModelName`, a list column the model does not have, an unknown field type. Nothing is parsed per request. The example below activates the admin for the plugin above:
At start-up `cabana.Activate` compiles every plugin's controllers, settings, navigation and permissions once. Any schema mistake stops the start-up with an error that names the plugin, the controller and the file: an unknown YAML key, a `modelClass` that does not match `pact.AdminController.ModelName`, a list column the model does not have, an unknown field type. Nothing is parsed per request. The one run-time hook is `pact.NavigationItem.Visible`, which runs on every `GET /navigation` so a plugin can hide a screen whose feature is off; it must be cheap and answer true when unsure. The example below activates the admin for the plugin above:
```go src=modules/cabana/example_test.go#ExampleActivate
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})

View File

@@ -66,7 +66,7 @@ func (p *BlogPlugin) Navigation() []pact.NavigationItem {
}
```
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open: a main menu item the administrator may not open is dropped even when one of its side-menu entries would pass, and an allowed item that links to a controller the administrator cannot open links to its first openable side-menu entry instead. `cabana.Allows` is the check and follows Winter's `hasAnyAccess` for JWT sessions: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. When the request carries a personal API token with a non-full scope, the scope is checked first (see [API tokens](#api-tokens)). The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open: a main menu item the administrator may not open is dropped even when one of its side-menu entries would pass, and an allowed item that links to a controller the administrator cannot open links to its first openable side-menu entry instead. A plugin can also hide an entry per request with `pact.NavigationItem.Visible` (for a screen whose feature is turned off in its settings); the same drop and relink rules apply. `cabana.Allows` is the check and follows Winter's `hasAnyAccess` for JWT sessions: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. When the request carries a personal API token with a non-full scope, the scope is checked first (see [API tokens](#api-tokens)). The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`.

View File

@@ -55,7 +55,7 @@ Use cases:
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
- Personal API tokens: administrators mint scoped tokens stored in `backend_api_tokens` (`cabana.BackendAPIToken`); only the sha256 hash and a four-character hint are kept. Framework scopes are `*` (full access as the owner on every method) and `admin:read` (as the owner on GET and HEAD); plugins add scopes through `pact.HasTokenScopes`. Interactive-only routes (account profile, avatar, token management, refresh and logout) refuse every personal token with `details.reason` `interactive_only`. Routes outside `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) are gated so only a full scope, or `admin:read` on GET, passes. Plugin routes resolve a backend principal with `cabana.ResolveBackend` (Bearer only; never the admin cookie).
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie. The same screen uploads and removes the administrator's avatar (POST and DELETE `/account/avatar`): a public attachOne in `system_files` under WinterCMS's morph name (`cabana.BackendUserMorphType`, field `cabana.BackendUserAvatarField`; `cabana.BackendUser` implements `attach.Owner` and `attach.HasRelations`), so avatar rows copied from WinterCMS are picked up as they are. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most `cabana.AvatarMaxBytes` (2 MiB), are stored, under a server-generated name. Plugins read the picture with `cabana.BackendUserAvatar`. The administrator's UI preferences (GET and PUT `/account/preferences`, `cabana.AdminPreferences`) live in the same metadata column under `admin_theme` and `admin_locale`; saving them keeps every other key.
@@ -80,7 +80,7 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in
| GET `/account/tokens`, POST `/account/tokens` | List the caller's personal API tokens (newest first; revoked only with `?revoked=1`) and create one (`cabana.AdminAPITokenRequest`: `name`, `expires`, `scopes`). Create answers 201 with `cabana.AdminAPITokenCreated` (plaintext `token` once). Interactive session only. |
| DELETE `/account/tokens/{id}` | Soft-revoke one of the caller's own active tokens (`cabana.AdminAPIToken`); 404 otherwise. Interactive session only. |
| GET `/account/tokens/scopes` | Scopes the caller may assign (`cabana.AdminAPITokenScope`), including `*`, `admin:read` and overlapping plugin scopes. Interactive session only. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open and, for a navigation entry with a `Visible` func, that it answers true for the request; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. |
| POST `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | Store one multipart part named `file` through the `Upload` of the field's plugin field type and answer 201 `{url}` (`cabana.FieldUploadResponse`). `{id}` 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 403 when the controller does not declare the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise), as the save routes answer. 404 for a field without a plugin type with `Upload` or whose context does not allow the operation, 413 over `http.body_limits.upload_bytes`, 422 on the field for a malformed body or a `pact.FormFieldError`. The body is read and checked whole before `Upload` runs, so a refused request stores nothing. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |

View File

@@ -35,7 +35,11 @@ type SettingsEntry struct {
}
// Metadata returns only entries the backend principal may open. Denied
// entries are removed before any response value is constructed.
// entries are removed before any response value is constructed. A navigation
// entry with a pact.NavigationItem.Visible func is also left out when that
// func answers false for ctx (a main item with its whole side menu, or one
// side item); Visible runs only after the permission check has passed, so a
// denied principal never runs plugin code.
func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, tr *phrasebook.Translator) ([]NavigationEntry, []SettingsEntry) {
navigation := make([]NavigationEntry, 0)
settings := make([]SettingsEntry, 0)
@@ -49,15 +53,29 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
if !Allows(principal, item.Permissions) {
continue
}
if item.Visible != nil && !item.Visible(ctx) {
continue
}
children := make([]NavigationEntry, 0)
// hidden collects the controllers of side items a Visible func hid
// for this request, so the parent never links to one of them.
// Permission-denied children are not recorded: the parent keeps its
// own controller whenever the principal can open it.
hidden := map[string]bool{}
for _, child := range item.SideMenu {
if !Allows(principal, child.Permissions) {
continue
}
if child.Visible != nil && !child.Visible(ctx) {
if child.Controller != "" {
hidden[child.Controller] = true
}
continue
}
children = append(children, navigationView(ctx, tr, child, nil))
}
view := navigationView(ctx, tr, item, children)
view.Controller = r.openableTarget(principal, item, children)
view.Controller = r.openableTarget(principal, item, children, hidden)
navigation = append(navigation, view)
}
sort.SliceStable(navigation, func(i, j int) bool {
@@ -95,11 +113,13 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
}
// openableTarget returns the controller a main item should link to. It is the
// item's own controller unless the principal cannot open it, in which case it
// is the first side-menu entry the principal can, so the menu never links to a
// page that answers 403. It is empty when nothing is openable.
func (r *Registry) openableTarget(principal *bouncer.Principal, item pact.NavigationItem, children []NavigationEntry) string {
if item.Controller == "" || r.canOpen(principal, item.Controller) {
// item's own controller unless the principal cannot open it or it is the
// controller of a side item hidden for this request (in hidden), in which
// case it is the first of the listed children (already visible and permitted)
// the principal can open, so the menu never links to a page that answers 403
// or to a screen whose feature is off. It is empty when nothing is openable.
func (r *Registry) openableTarget(principal *bouncer.Principal, item pact.NavigationItem, children []NavigationEntry, hidden map[string]bool) string {
if item.Controller == "" || (!hidden[item.Controller] && r.canOpen(principal, item.Controller)) {
return item.Controller
}
for _, child := range children {

View File

@@ -108,6 +108,74 @@ func TestNavigationDropsDeniedParentAndRepointsTarget(t *testing.T) {
}
}
type navVisibleKey struct{}
// TestNavigationHidesInvisibleItemsAndRepointsTarget covers the
// pact.NavigationItem.Visible rules: a side item whose Visible answers false
// is left out while a sibling with no Visible stays, a parent whose own
// controller is that hidden child's links to the first visible openable child
// (or to nothing when none is), a parent whose Visible answers false is
// dropped with its whole side menu, and Visible receives the context passed
// to Metadata. The principal is a superuser so permissions are not a factor.
func TestNavigationHidesInvisibleItemsAndRepointsTarget(t *testing.T) {
hidden := func(context.Context) bool { return false }
shown := func(context.Context) bool { return true }
var seen []any
fromCtx := func(ctx context.Context) bool {
seen = append(seen, ctx.Value(navVisibleKey{}))
return true
}
reg := &Registry{
byID: map[string]*CompiledController{
"acme.shop.orders": {Controller: navController{"acme.shop.orders", nil}},
"acme.shop.payments": {Controller: navController{"acme.shop.payments", nil}},
"acme.shop.quotes": {Controller: navController{"acme.shop.quotes", nil}},
},
navigation: []pact.NavigationItem{
{
Code: "shop", Label: "Shop", Controller: "acme.shop.orders", Order: 1,
SideMenu: []pact.NavigationItem{
{Code: "orders", Label: "Orders", Controller: "acme.shop.orders", Visible: hidden},
{Code: "payments", Label: "Payments", Controller: "acme.shop.payments"},
{Code: "quotes", Label: "Quotes", Controller: "acme.shop.quotes", Visible: fromCtx},
},
},
{
Code: "off", Label: "Off", Controller: "acme.shop.payments", Order: 2, Visible: hidden,
SideMenu: []pact.NavigationItem{
{Code: "payments", Label: "Payments", Controller: "acme.shop.payments", Visible: shown},
},
},
{
Code: "empty", Label: "Empty", Controller: "acme.shop.quotes", Order: 3,
SideMenu: []pact.NavigationItem{
{Code: "quotes", Label: "Quotes", Controller: "acme.shop.quotes", Visible: hidden},
},
},
},
}
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
ctx := context.WithValue(context.Background(), navVisibleKey{}, "request")
nav, _ := reg.Metadata(ctx, super, nil)
if len(nav) != 2 || nav[0].Code != "shop" || nav[1].Code != "empty" {
t.Fatalf("navigation = %#v, want shop and empty (the off parent must be dropped although its child is visible)", nav)
}
shop := nav[0]
if len(shop.SideMenu) != 2 || shop.SideMenu[0].Code != "payments" || shop.SideMenu[1].Code != "quotes" {
t.Fatalf("shop side menu = %#v, want payments and quotes (orders hidden)", shop.SideMenu)
}
if shop.Controller != "acme.shop.payments" {
t.Fatalf("shop controller = %q, want the first visible openable child", shop.Controller)
}
empty := nav[1]
if len(empty.SideMenu) != 0 || empty.Controller != "" {
t.Fatalf("empty = %#v, want no side menu and no controller", empty)
}
if len(seen) != 1 || seen[0] != "request" {
t.Fatalf("Visible saw context values %v, want the one passed to Metadata", seen)
}
}
// TestRelationMutationsFollowToolbarButtons pins WR-05: link and unlink are
// refused unless the relation's view panel declares them.
func TestRelationMutationsFollowToolbarButtons(t *testing.T) {

View File

@@ -24,7 +24,7 @@ Use cases:
- Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`.
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page. A `pact.NavigationItem.Visible` func hides a navigation entry at request time (nil = always listed).
- Personal API token scopes: `pact.TokenScope` and `pact.HasTokenScopes`, so a plugin declares named scopes (permissions and HTTP methods) that administrators can assign when creating a personal API token.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids; a `Detached` action runs outside the framework transaction), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
@@ -119,7 +119,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasPermissions` | Declares backend permissions. |
| `pact.TokenScope` | One personal API token scope: name, label, description, permission patterns and HTTP methods. |
| `pact.HasTokenScopes` | Declares personal API token scopes compiled at admin boot. |
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu. |
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu and an optional `Visible` func the admin runs on every navigation request. |
| `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. |
| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. |
| `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. |

View File

@@ -537,7 +537,10 @@ type HasTokenScopes interface {
}
// NavigationItem is one registerNavigation() entry. Controller is the admin
// controller ID; the SPA derives its route from that ID.
// controller ID; the SPA derives its route from that ID. Every field but
// Visible is static and compiled once at boot; Visible is the one hook the
// admin runs per navigation request, so a plugin can hide a screen whose
// feature is turned off in its settings without a restart.
type NavigationItem struct {
Code string
Label string
@@ -546,6 +549,14 @@ type NavigationItem struct {
Order int
Controller string
SideMenu []NavigationItem
// Visible decides, per request, whether the entry is listed. Nil means
// always listed. When set, the admin (cabana) calls it on every
// navigation request with the request context, after the permission
// check has passed, and leaves the entry out when it answers false; a
// main item hidden this way takes its whole side menu with it. The func
// must be cheap, must not panic, and should answer true when it cannot
// decide (no database yet, a load error).
Visible func(ctx context.Context) bool `json:"-"`
}
// HasNavigation is implemented by plugins that declare backend navigation.