ADR 0044 — Per-action requires: role gate
- Status: accepted
- Date: 2026-10-05
- Slice: P44 (builder-roadmap: DSL-first, codegen-first, UI-first)
Context
P36 (ADR 0036) gave list pages row/toolbar actions bound to CQRS commands,
and P40 (ADR 0040) gave the shell an advisory client-side role filter
(filterNav, driven by /api/auth/me + MOSAIC_ROLE_LEVELS) that drops any
[data-roles] element whose minimum role bar exceeds the caller's level. But
an action could not declare a minimum role: every action button was rendered
for every authenticated caller. The server-side command auth was still the
real gate, so an under-privileged caller saw a button that would 403 on click
— visible but unusable.
The pieces were already in place: P40's filter understands data-roles on
arbitrary elements, and every app has a role ladder (app.role_levels,
default mosaic:standard). What was missing was a DSL key to attach a
minimum role to an action and a plan-time check that the role exists.
Decision
1. ui.actions[].requires (DSL)
A page action may now declare a minimum role:
ui:
actions:
- label: "activate"
command: "RuleSet.Activate"
placement: row
confirm: "activate?"
requires: editor # a role on the app's role ladder
requires names a role that must exist on the app's role ladder. It is a
client-side advisory declaration: it controls whether the button is shown,
not whether the command runs. The command's own auth (its min_role/
permission/where, enforced server-side at dispatch) remains the final gate.
2. Plan-time validation (fail-closed)
The action's requires role is checked against ws.app.role_levels at plan
time. An unknown role is the fail-closed ui-action-requires-role diagnostic
("… is not on the app's role ladder (declared: …)") and the action is
dropped. The fail-closed posture follows from the filter's semantics: an
unmatched role contributes no level to the bar, so its minimum stays at max
and the element is hidden for everyone — a silent, always-hidden button is
worse than a loud plan error.
The validated role rides on UiActionPlan.requires (carried through the
action resolution unchanged; a plan diagnostic does not abort the build, it
just skips that action).
3. Rendering (data-roles on the action button)
action_roles_attr(a) returns data-roles="<role>" when the action has a
requires, else "". It is spliced into every action button site — the three
toolbar variants (dialog / confirm / plain) and the three row variants — so the
button carries class="wf-tb" data-roles="<role>". P40's filterNav then does
the rest on load (and after /api/auth/me resolves): it reads
window.MOSAIC_ROLE_LEVELS, fetches the caller's level, and removes any
[data-roles] element whose minimum role bar exceeds it. No new client
mechanism, route, or JS ships — P44 only adds the attribute and the plan check.
Consequences
- An action can now be role-gated client-side: an under-privileged caller never sees the button (matching the nav-item gating P40 already provided for nav entries and palette items).
- Reuses the existing P40
filterNav+ the app's role ladder — no new runtime, no new route, no new client JS. The change is one attribute on the button + one plan-time check. - Advisory, not authoritative. The
data-rolesattribute only hides the button; a caller can always dispatch the command directly via REST. The command's own server-side auth is the real gate, and arequiresthat is stricter than the command's own auth is redundant (the server would reject it anyway) while one that is looser just shows the button to people the server will then reject.requiresis a UX affordance, not a security boundary. - View caveat. Action buttons render only on
table-view list pages; thecardsandkanbanlenses render no action buttons (rows_view_cardtakes no actions). So in thefull-stackexample — whose only action-bearing projection,RuleSetList, is akanbanview — therequiresis exercised at plan time (role validation in the conformance build) while the actualdata-rolesbutton render is pinned by the render test on atable-view projection. This is the same verification posture as P36's action buttons, which no example renders in a golden (all action-bearing example projections are non-table). data-rolesis adata-*attribute on a Leptos element — the same class as P43'sdata-show-if/data-wfstep, which are proven to compile in thefull-stack-webSSR build.
Verification
- Render test (
action_requires_renders_data_roles_on_the_button): atable-view projection whose row action and toolbar action each declare arequiresrole rendersclass="wf-tb" data-roles="editor"andclass="wf-tb" data-roles="manager"; an action with norequiresrenders nodata-roles. - Plan test (
action_requires_unknown_role_is_a_plan_error): arequiresnaming a role not on the app's (standard) ladder is the fail-closedui-action-requires-rolediagnostic. - Example:
full-stackdeclaresrequireson the RuleSetList actions (activate→editor,deactivate→manager); it plans green in the conformance build (the roles are validated end-to-end). - Gates: 477 workspace tests, clippy
-D warnings,cargo fmt --check, and conformance (green — no golden regression, since the only action-bearing example projection iskanbanand renders no action buttons, so the emittedpages.rsis byte-identical to the pre-P44 golden).