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

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