Skip to content

feat(auth): gate staff features on staffPermissions instead of role names - #1623

Merged
DavidCockerill merged 1 commit into
stagefrom
david/1622-staff-permissions
Aug 14, 2026
Merged

feat(auth): gate staff features on staffPermissions instead of role names#1623
DavidCockerill merged 1 commit into
stagefrom
david/1622-staff-permissions

Conversation

@DavidCockerill

Copy link
Copy Markdown
Member

Closes #1622

What

GET /User/current returns a staffPermissions array describing exactly what a staff account may do, but the UI never read it — staff gating was hardcoded to fabricRole === 'fabric_admin' (plus super_user). That was wrong in both directions: the narrower staff roles saw no staff features at all, and admin mode rendered actions the API has since withheld from fabric_admin (org delete, cluster delete), which 403 on click.

This replaces every role-name gate with the specific permission its surface needs, via a new hasStaffPermission(user, permission):

Surface Now gated on
All Orgs toggle, entity-id jump, OAuth-lock bypass org:read
OrgCard "Add Coupon" billing:write
OrgCard "Delete" + org permissions hook org:delete / org:update (unioned with the member role)
Org roles hook role:read/create/update/delete
Cluster hook cluster:read/create/update/delete
Instance hook instance:read/update; create/remove follow the cluster grants (instances are added/removed through cluster updates)
"Cleanup Orphan Blobs" instance:update
/admin Notifications systemStatus:write
/admin Regions region:read (page), region:write (create/edit buttons)
/admin API Token apiToken:create

Behavior notes

  • usePermissions hooks union, not shadow: a staff account that is also an org member keeps whatever its member role grants, and vice versa — matching the API's own semantics.
  • /admin opens per-page: the rail shows only the pages the account holds, holding any of them grants entry, and deep links to unheld pages bounce to the first visible one. The /admin index redirect is unchanged.
  • super_user never sees the API Token page, even though it holds the permission: token minting requires the Google SSO session staff sign-ins have, and super_user may password-login — the mint would 403. This preserves the invariant the old isFabricAdmin gate encoded, but per-page instead of hiding the whole section.
  • Older backends keep today's behavior: when staffPermissions is absent from the response, hasStaffPermission falls back to the legacy role-name check, so deploying this UI ahead of the API changes nothing.
  • isAdminMode / useAdminMode / isFabricAdmin are gone — nothing references role names for gating anymore.

Testing

  • New: hasStaffPermission unit tests (granted/withheld/customer/local/legacy-fallback), /admin visibility per role incl. the super_user carve-out, and cluster/instance hook mapping + union tests.
  • Full suite: 293 files, 2268 passed / 11 skipped — same as stage baseline plus the new tests. tsc, oxlint, and dprint clean.

🤖 Generated with Claude Code

…ames

/User/current has returned a staffPermissions array since the API's granular
staff roles landed, but the UI still gated everything on
fabricRole === 'fabric_admin' — narrower staff roles saw nothing, and admin
mode promised actions (org/cluster delete) the API has since withheld.

hasStaffPermission() reads the array (falling back to the old role-name
behavior when the API predates the field) and every staff surface now names
the permission it needs. The /admin section admits any account holding at
least one of its pages' permissions; the API Token page stays hidden from
super_user, whose password sessions can't satisfy the mint's SSO requirement.

Closes #1622

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@DavidCockerill
DavidCockerill requested a review from a team as a code owner August 14, 2026 14:36
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 55.48% 6704 / 12083
🔵 Statements 56.08% 7215 / 12865
🔵 Functions 47.66% 1661 / 3485
🔵 Branches 49.65% 4716 / 9497
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/components/Navbar.tsx 30% 20.93% 15.78% 32.43% 34-44, 100-123, 166-313
src/features/admin/routes.ts 77.77% 100% 66.66% 77.77% 33-34, 40-41
src/features/admin/components/AdminShell.tsx 29.41% 46.66% 60% 33.33% 41-63
src/features/admin/notifications/mutations/useNotificationMutations.ts 0% 100% 0% 0% 22-48
src/features/admin/regions/index.tsx 13.33% 10% 15.38% 14.28% 31-138
src/features/instance/databases/components/DatabaseTableView.tsx 0% 0% 0% 0% 72-572
src/features/organizations/index.tsx 0% 0% 0% 0% 28-262
src/features/organizations/components/OrgCard.tsx 71.42% 54.54% 20% 71.42% 40, 78, 111-139
src/hooks/useAuth.ts 50% 61.53% 41.66% 54.54% 17-26, 63-69
src/hooks/usePermissions.ts 35.22% 37.9% 47.05% 35.63% 44-50, 71-92, 111, 114, 116, 127-137, 158-165, 177-239
Generated in workflow #1715 for commit 91a4e4d by the Vitest Coverage Report Action

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request transitions the frontend authorization model from coarse-grained role checks (such as isFabricAdmin and useAdminMode) to a fine-grained, permission-based system using a new staffPermissions array. It introduces the hasStaffPermission and useStaffPermission hooks, updates UI components (like the Navbar, AdminShell, OrgCard, and Regions index) to gate features based on specific permissions, and refactors permission hooks to union staff permissions with organization membership permissions. Comprehensive unit tests have also been added to verify the new permission-checking logic. I have no feedback to provide as there are no review comments.

@dawsontoth dawsontoth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

@DavidCockerill
DavidCockerill added this pull request to the merge queue Aug 14, 2026

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. A few codex suggestions if you are interested, while this sits in the queue.

🤖 Reviewed with Codex

const [search, setSearch] = useState('');
// The page itself only needs region:read; creating/editing posts to the
// region:write-gated endpoints.
const canWriteRegions = useStaffPermission('region:write');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A user with only region:read is now intentionally admitted to this page, but the component unconditionally runs getOrganizationsQueryOptions(), whose /Admin/Organization/ request is gated by org:read. The explicit fabric_readonly test case therefore gets a global 403 toast on page load, loses organization names, and cannot filter scopes by name. Please either require org:read for this page or conditionally enable the organization query and provide a read-only fallback for region-only users; the modal should apply the same rule. — KrAIs (GPT-5)

// straight to an org from an id copied out of a log or ticket.
const searchEntityId = useMemo(() => {
if (!isAdminMode) {
if (!canReadAllOrgs) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This single org:read check also enables pasted cluster-ID searches, but that branch first calls /Cluster/{id} before looking up the organization. An account with org:read but no cluster:read will therefore get a 403 instead of the promised exact-ID lookup. Please gate cluster-ID detection on cluster:read as well, or resolve the cluster through an organization-admin endpoint authorized by org:read. — KrAIs (GPT-5)

// instance:create / instance:delete permissions), so those verbs follow
// the cluster grants.
create: hasStaffPermission(user, 'cluster:update') || member.create,
remove: hasStaffPermission(user, 'cluster:delete') || member.remove,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adjacent comment correctly says adding and removing instances happens through cluster updates, but remove is mapped to cluster:delete. That denies instance removal to staff who can update a cluster and grants it to staff who may delete a cluster but cannot update it; the latter operation would then be rejected by the update endpoint. Please map both instance create and remove to cluster:update and add the symmetric test assertion. — KrAIs (GPT-5)

Merged via the queue into stage with commit e2b2e54 Aug 14, 2026
2 checks passed
@DavidCockerill
DavidCockerill deleted the david/1622-staff-permissions branch August 14, 2026 20:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Staff features gate on hardcoded role names instead of staffPermissions

3 participants