Skip to content

Menu and Settings Visibility

Access is enforced by the engine: a model’s ModelAccess decides who may read it, and no menu, view or route can widen that. Visibility is the other half — what a user is shown — and it is derived from the same access rather than declared a second time.

That distinction is the whole rule. You do not maintain a group list on every menu to match the grants you already wrote; you write the grants, and the navigation follows.

A menu appears when all of these hold:

  1. Its module is licensed. Menus owned by a licensed module are hidden without a licence.
  2. The user holds one of its groups, if it declares any. Optional, and a narrowing layer — see below.
  3. Its visible_setting Q-expression holds against the current configuration values.
  4. The user may READ the model its action opens. Decided against the same merged, implied-group-flattened access the engine enforces reads with, so a menu can never disagree with what happens when it is clicked.
  5. It leads somewhere. A menu is visible if it opens something itself, or if one of its descendants is visible. An app whose every screen is inaccessible disappears from the launcher rather than opening onto nothing.

Two cases skip rule 4 deliberately:

  • A menu onto a transient model — a wizard, a settings form. Transient records are never persisted, carry no ModelAccess by design, and are never access-checked; what gates them is the permission checked when they confirm, on the real records they write.
  • A menu whose action carries no model — it is gated by groups / visible_setting alone.

A menu pointing at an action that no longer exists is treated as having no action: it is hidden, rather than rendering a click that goes nowhere.

Declaring groups: on a menu narrows it below what the ACLs allow — “this screen is for managers even though users can read the model”. It is not how a menu becomes visible, and it is not a place to restate the model’s permissions. A menu with no groups at all is the norm: its audience is whoever may read what it opens.

- data_type: UiMenu
name: Licences
identifier: licenses_menu
parent: licensing_root_menu
action: license_action # opens License -> shown to whoever may read License
sequence: 10

Settings tabs follow the module that contributed them

Section titled “Settings tabs follow the module that contributed them”

Every app contributes one tab to the shared Configuration form, and that form is a transient model any internal user may open. So a settings tab is shown to the holders of its contributing module’s groups — a user who holds none of them does not run that app, and its settings are not theirs to see.

As with menus, an explicit properties.groups on the tab is a second, narrower layer (an app whose settings are for its managers only), not the thing that makes the tab appear. A module that declares no groups of its own has nothing to derive from, and its tab stays visible.

settingsLinks inside a tab are filtered the same way they always have been: by the read access on the model each link opens. Nothing needs to restate that per link.

A database links its administrator to exactly one group when it is created. A module installed afterwards grants nothing on its own — so an app whose menus are gated by its own group, or whose models are granted only to it, is invisible to the very person who installed it, with no error anywhere.

The mechanism for fixing that already exists: implied_groups on the administrator group. Every module declares the link in its own security/ file, next to the groups it defines:

- data_type: Group
name: Manager
identifier: sales_manager_group
category: Sales
implied_groups:
- - link
- sales_user_group
# The administrator gets everything this module grants.
- data_type: Group
identifier: core_admin
implied_groups:
- - link
- sales_manager_group

It is a per-module declaration on purpose. A module may deliberately keep a group out of the administrator’s reach — core_portal and core_public are external by definition — and an installer that inferred the link would quietly undo that decision.

There is no admin bypass at the model level: an administrator with no grant on a model cannot read it, and the menu that opens it is hidden from them exactly as it is from anybody else. Implying the group is what grants it.

./fullfinity-server check --only menus reads the module tree — no database — and reports the authoring mistakes that visibility-by-derivation turns silent, because the entry point simply is not there:

M1a menu gated to a group that cannot read its action’s model
M2a menu whose action’s model has no read grant in any module
M3a module group the administrator cannot reach through implied_groups
M4a menu naming an action no module declares
M5a settings tab whose module declares no groups and which names none itself

It runs in CI and on commit. Findings that are correct as they stand are recorded in engine/menu_exceptions.yaml with a written reason.