2026-05-29 — RBAC-aware UI: /api/me/permissions + <PermissionGate> (#6)¶
Motivation¶
User requirement #6 from the project review:
RBAC-driven disabled UI with tooltip — a user who lacks Contributor on a cluster RG should see Start/Stop/Delete/Submit/Build buttons already disabled with a tooltip explaining which role they need, not click through to a silent 403.
Previously the only feedback was a toast after the failed PUT, by
which time the user had already wondered whether the click registered.
This change adds a backend endpoint that resolves the calling user's
effective Azure RBAC capabilities at a scope, a React Query hook that
caches the answer for 60 s, and a <PermissionGate> component that
disables (or hides) a clickable element with a "you have X, you need
Y" tooltip.
User-facing change¶
- New endpoint
GET /api/me/permissions?subscription_id=…[&resource_group=…&cluster_name=…]returns a structured capability shape (can_read,can_write,can_start_stop,can_delete,can_submit_blast,can_build_acr,can_grant_rbac,degraded,matched_roles,matched_role_names,reason). Cached server-side for 60 s per(caller_oid, scope)pair. - Auto-stop panel now disables the Enable / Idle-minutes /
Extend controls for users without
Contributor(or equivalent) on the cluster RG, with a tooltip explaining the missing role. Read-only users (Reader at sub) see the panel but cannot mutate it; they still see the live verdict / countdown. - Degrade-open behaviour: if the role enumeration call itself
fails (ARM hiccup, caller lacks
roleAssignments/read, …), every capability is set totrueanddegraded=trueis returned. The SPA must not lock the operator out on a transient backend error — ARM still enforces real authorization at submit time. This is a UX affordance, not a security boundary.
API / IaC diff summary¶
Backend (api/)¶
| File | Change |
|---|---|
| api/services/me_permissions.py | New — role GUID → capability mapping; compute_caller_permissions(credential, caller_oid, sub, rg=None, cluster=None); 60 s LRU-bounded cache (1024 entries); degrade-open on enumeration failure |
| api/routes/me.py | New GET /me/permissions route |
| api/tests/test_me_permissions.py | New — 10 tests covering Owner/Reader/Contributor/UAA mapping, scope-inheritance direction (descendant scope must NOT bubble up), cache behaviour, empty-oid path, degrade-open path |
| api/tests/test_me_route.py | +2 tests for the new route (shape contract + 422 missing-query validation) |
Frontend (web/)¶
| File | Change |
|---|---|
| web/src/api/me.ts | CallerPermissionsResponse interface + meApi.permissions(subId, rg?, cluster?) typed client |
| web/src/hooks/usePermissions.ts | New — usePermissions(subId, rg?, cluster?) returns { permissions, isLoading, isError, error }; fallback OPEN_PERMISSIONS keeps every flag true while loading / errored (matches backend degrade-open contract) |
| web/src/components/PermissionGate.tsx | New — <PermissionGate need="can_*" permissions={…}>{children}</PermissionGate> disables the wrapped element (or hides it via hideInsteadOfDisable) with a "You do not have permission to X. You hold: Y. You need: Z." tooltip when the capability is false. Stays open when degraded=true |
| web/src/components/PermissionGate.test.ts | New — 5 tests pinning permissionDeniedTooltip for every capability |
| web/src/components/ClusterItem/AutoStopPanel.tsx | Wraps the Enable checkbox, the Idle-minutes select, and the Extend button in <PermissionGate need="can_write" permissions={…}> (first consumer; serves as the wiring template for future RBAC-gated controls) |
IaC¶
No infra changes in this wave.
Validation evidence¶
$ uv run pytest -q api/tests
............................................................... [100%]
1898 passed, 3 skipped in 33.70s
$ cd web && npm test -- --run
Test Files 56 passed (56)
Tests 433 passed (433)
$ uv run ruff check api
All checks passed!
$ cd web && npm run build
✓ built in 7.39s
Self-review¶
- Consumer search for
meApi.getconfirmed addingmeApi.permissionsis a pure addition; no existing call site touched. - Consumer search for
<PermissionGate>confirmed AutoStopPanel is the only current wiring; charts a clear template for the next RBAC-gated surface (Submit, Start/Stop, Delete buttons in subsequent waves). - The fallback
OPEN_PERMISSIONSinusePermissions.tscarriesdegraded: trueso the<PermissionGate>early-return on degraded stays open during the initial network round-trip; no flash-of-disabled state. - Cache TTL (60 s server, 60 s client) keeps role-list ARM calls below one per page per scope per minute. The cache key shape mirrors the ancestor-inclusion test so cross-scope mistakes are caught.
- Type contract:
CallerPermissionsResponsematches the backendCallerPermissions.to_dict()exactly; the new route test pins every key so a renamed field on either side fails CI.
Not covered in this wave (followups)¶
- Wiring
<PermissionGate>to the cluster Start/Stop/Delete buttons, the BLAST Submit button, and the ACR build trigger. - Promoting the in-process permissions cache to Redis (matches autostop status #18; deferred until polling load actually warrants it — current usage is once per page).
- A dedicated
<PermissionTooltip>variant for elements that cannot acceptdisabled(e.g. anchor links).