#### Description
Applies the authz list-page pattern to the dashboards list and gates the
row actions per dashboard.
- **The table is blocked** without `list`, via an inline callout. **New
dashboard stays enabled** if the caller holds `create` — it is
independent of `list`.
- **Everything that only feeds the table goes with it.** The search box
and the filter chips edit a query that can only be run through the list
request, so they are disabled rather than hidden, and each carries the
standard denial on hover. Two tooltip zones cover them — the search
field, and the chips with Clear — instead of one tooltip per control.
- **The views rail is one guarded block**, not a column of dead
controls. Every view leads to a table the caller cannot see, and the
saved views behind it need the same grant, so the rail carries a single
callout. That also drops an empty state that was asserting something it
could not know ("No saved views yet" when the request was never made).
- **Row actions run their own checks** through `AuthZButton`, so the
menu opens immediately. View, Open in New Tab and Copy Link need no
permission and no longer wait behind the gated rows; all nine rows now
render through the same component rather than three of them being plain
buttons.
- **Delete gates on `delete` alone** (guide rule 3), so it survives
without `read`. **Rows are never gated** (rule 4): the list is
collection-scoped and deliberately returns rows the caller cannot
`read`, so every row stays clickable and the denial is explained on
arrival.
- **Per-row checks fire lazily**, when a menu opens rather than for all
20 rows, and the menu itself renders lazily — it previously evaluated
its whole tree and instantiated two mutations per row on every render.
- **Viewers can manage saved views again.** Save, rename and delete were
behind `edit_dashboard`; the backend only requires `list`, and the views
request now follows the same grant.
- **The row menu's lock gates on the dashboard's `source`**, not on who
created it — an integration dashboard can never be locked, and
everything else is decided by `update`.
- **Route gating**: the three dashboard routes bypass the legacy role
check, so an FGA user with no managed role isn't bounced to
`/un-authorized`.
- **Guide updated to match.** The list-page rule previously said to
leave the search, filters and views interactive. It now says to disable
what only shapes a blocked request, and to gate a region once rather
than repeat a tooltip per control.
#### Screenshots
<img width="1920" height="992" alt="Screenshot 2026-09-10 at 11 13
20 PM"
src="https://github.com/user-attachments/assets/b590ddcd-4b7b-4e82-8a51-ef5ffae68623"
/>
<img width="1571" height="275" alt="Screenshot 2026-09-10 at 11 14
09 PM"
src="https://github.com/user-attachments/assets/f0a6f32c-ff1d-4a9c-81ae-8e3ae4832ecf"
/>
<img width="570" height="363" alt="Screenshot 2026-09-10 at 11 22 32 PM"
src="https://github.com/user-attachments/assets/2f42e918-ae06-499e-8700-5deba46b987a"
/>
<img width="547" height="320" alt="Screenshot 2026-09-10 at 11 16 41 PM"
src="https://github.com/user-attachments/assets/b5e287b1-9178-4165-866f-e914348d6e39"
/>
<img width="518" height="345" alt="Screenshot 2026-09-10 at 11 17 00 PM"
src="https://github.com/user-attachments/assets/ac22cd2f-2aa1-499f-b3cf-4f9014bf6ea3"
/>
<img width="498" height="313" alt="Screenshot 2026-09-10 at 11 18 45 PM"
src="https://github.com/user-attachments/assets/72c252f5-5616-4a44-b645-3074a6d9fc12"
/>
<img width="561" height="311" alt="Screenshot 2026-09-10 at 11 18 52 PM"
src="https://github.com/user-attachments/assets/923f5c48-b272-4278-9951-8a9122b2732c"
/>
<img width="588" height="322" alt="Screenshot 2026-09-10 at 11 16 31 PM"
src="https://github.com/user-attachments/assets/5061c115-57b4-4fdd-aa9d-9f43cd539be6"
/>
#### Additional Information
- `DASHBOARD` and `DASHBOARD_PANEL_EDITOR` are registered for authz
here, though their pages ship in #12438. Harmless in this order — until
this PR merges those routes keep their legacy role checks, which is the
fail-safe direction.
- Two callouts show when `list` is denied, one on the rail and one where
the table would be. They are separate blocks with separate reasons to
exist, but it is more denial than the guide's "state it once".
- A 403 from the list request still renders as "Invalid query", because
`ErrorState` treats every 4xx as a client error. Pre-existing and not
authz-specific, so left alone.
- Saved views are org-shared with no ownership rule, so anyone holding
`list` can delete anyone's view. Unchanged here — flagging it as a
product question.
- Requires #12438. Rebased onto current `main`; shares
`utils/permission/index.ts` and `AppRoutes/__tests__/Private.test.tsx`
with #12448, which merges without conflict.
7.5 KiB
AuthZ Guide
How to structure a page so it works with the permission system.
We are migrating from the ADMIN | EDITOR | VIEWER roles to per-action checks. Instead of granting VIEWER and
exposing everything, a user can now be granted access to a single resource, eg: Logs only.
Prerequisites
Check whether the resource your page represents is supported: see permissions.type.ts for the current resources and their allowed verbs.
If the resource is not listed there, skip authz for now. The backend does not enforce it yet, so any frontend check would be decorative. Revisit once the resource is generated into that file (it is auto-generated from the backend).
Core rules
These hold for every page. The per-pattern sections below only add to them.
- A page is always reachable, regardless of permission. The intended action must be known before permission can be checked, so the route renders first and individual pieces are gated.
updaterequires bothreadandupdate. A user who can write but cannot read the current value must not get an edit affordance.deleteis independent ofread. Delete controls stay visible for a user who holdsdeletebut notread.- Never gate per row. If a user can
list, render every row. Checkreadonly when the row is opened (drawer or detail route). - Gate the narrowest thing that works, a button over a section, a section over a page.
- Gate a page on
readalone. It is the only verb the page's own request needs.updateanddeletegate individual controls, so waiting for them holds up the whole page for nothing — pass them aspreloadChecksand they resolve in the same request, leaving the controls to read from cache.
- Gate a page on
- A resource may be gated while a sub-resource is not. A user without
readon Service Accounts can still holdcreateon API Keys, so blocking the outer container would hide work they are allowed to do. - Verbs not covered here (
attach,detach,assignee) behave likedelete: gate the control that triggers them, not the surrounding content.
Page patterns
List page
Without list, but with any of read / create / update, the table and
everything that only feeds it are blocked:
- Title, description, search, filters and action buttons stay visible. Nothing is hidden for lack of permission.
- Disable what only shapes the blocked request — the search box, the filter chips, a Clear button — and give it the same denial. It edits a query that has nowhere to run, so leaving it live invites the user to compose a filter and watch nothing happen.
- Gate a region as one section when several of its controls are dead. A saved views rail is a block with a single callout, not a column of identical tooltips; a row of filters is one tooltip zone, not one per control.
- The create button stays enabled if the user holds
create. It is independent oflist.
Edit page
Without read:
- Block the content with
withAuthZContent, not the whole route. - Keep delete visible (rule 3).
- Keep update blocked (rule 2).
Drawer
Without read:
- Block the drawer body, not the drawer itself.
- Prefer routing that carries the resource ID in the URL, so delete stays reachable without
read. - Keep update blocked (rule 2).
- Watch for sub-resources the user may still be allowed to act on (rule 6).
Create
Without create:
| Entry point | Gate with |
|---|---|
| Button | AuthZButton |
| Dedicated create page | withAuthZPage |
| Create drawer opened directly (deep link) | withAuthZContent on the body + AuthZButton on footer actions |
Delete
Gate the control with AuthZButton, or AuthZTooltip for a non-button trigger such as an icon or menu item.
Quick filters
What "blocked" means
Blocking is always a visible denial, never a silent removal. Use the components in
lib/authz/components rather than hand-rolling a check, they carry the
denial message and the loading state.
Denial copy comes from the components. Never write a custom message for a permission check. disabledTooltip is
for a block that is not a permission — a lock, an immutable resource, a mount that is deliberately read-only. It
takes precedence over the checks, which are then skipped, so set it only when that block is the real obstacle: a
missing permission must still surface its own wording.
| Scope | Component | Denied state |
|---|---|---|
| Button | AuthZButton |
Disabled + tooltip |
| Any element | AuthZTooltip |
Disabled child + tooltip |
| Section | withAuthZContent / AuthZGuardContent |
Inline PermissionDeniedCallout |
| Page / route | withAuthZPage / AuthZGuardPage |
PermissionDeniedFullPage |
| Custom fallback | withAuthZ / AuthZGuard |
Whatever you pass as fallback |
Prefer the HOC (withAuthZ*); reach for the JSX guard (AuthZGuard*) when the gate depends on conditional rendering
and an HOC cannot express it. The components README has the full decision tree and how to build the checks array.
Migration checklist
Use the existing components. Create a new one only if none fit.
- Can I apply a
withAuthZ*variant directly, or must I extract the content into a component first?- If extraction is needed, declare the new content component in the same file to keep the diff small, then move it to its own file in a follow-up commit or PR.
- Is the layout structured to respect the core rules?
- If not, raise it in the frontend Slack channel before reshaping the page.
- Am I gating a shared component rather than a page or section?
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when the user cannot read them.
Testing
Devtool
Press Cmd + K (Ctrl + K on Windows/Linux) to open the shortcuts, search for AuthZ, and pick the first
result. The devtool simulates granted and denied permissions across the UI.
Available only in local development, it is stripped from production builds.
Unit tests
lib/authz/utils/README.md covers the *.authz.test.tsx naming convention, the
MSW handlers (setupAuthzAdmin, setupAuthzDenyAll, setupAuthzDeny, setupAuthzAllow,
setupAuthzGrantByPrefix), and how to test the loading state.