Skip to content

Admin routes have no route-level authorization, only a hidden nav link #1470

Description

@joshunrau

Summary

No route under /admin has a route-level authorization check. _app/route.tsx's beforeLoad verifies that an access token exists, not what role holds it, and no admin route adds one of its own. The sidebar hides the links from unauthorized roles, but that is cosmetic — a pasted URL, a bookmark or browser history bypasses it.

What a non-admin gets today

  • /admin/users, /admin/settings, /admin/branding render fully, with a working "Save" / "Add User" UI, for both STANDARD and GROUP_MANAGER.
  • /admin/groups, /admin/instrument-repos do the same for GROUP_MANAGER (STANDARD gets a 403 crash screen on these two, because their loaders read endpoints it holds no rule for).
  • /group/manage renders and submits for STANDARD, which holds only read on Group.
  • /admin/audit/logs is the only one blocked for both roles, because its loader is gated on manage all.

This is a UX / defence-in-depth problem, not privilege escalation

The API refuses every privileged request those screens can fire. Verified endpoint by endpoint:

Screen Request Non-admin result
/admin/users POST / PATCH / DELETE /v1/users 403
/admin/settings, /admin/branding PATCH /v1/setup 403
/admin/instrument-repos POST /v1/instrument-repos 403
/admin/audit/logs GET /v1/audit/logs 403
/admin/groups, /group/manage PATCH / DELETE /v1/groups/:id refused unless a member
/admin/groups/create POST /v1/groups succeeds for GROUP_MANAGER — separate issue

Reads are row-scoped the same way, so the populated tables are not a leak: GET /v1/users returns only the acting user's own group for a GROUP_MANAGER, and only their own account for a STANDARD user.

So the actual user-visible defect is a fully-rendered admin screen whose every button fails.

Secondary problem: the failure mode where a route is blocked

A full-page SOMETHING WENT WRONG / 403 - Forbidden crash screen, rather than a redirect. /dashboard already does the nicer thing — an explicit beforeLoad/loader redirect to /session/start-session for a role that should not be there. Admin routes should follow that pattern.

Suggested fix

A shared beforeLoad on the /admin layout route that reads the acting role from the store (useAppStore.getState(), as _app/route.tsx does) and redirects rather than rendering. Related: #1355.

Notes

Found while writing the Playwright suite (branch e2e-tests). testing/src/specs/authorization.spec.ts now asserts the server-side half — every privileged request refused, and both read-scoping cases — so a fix here can be verified without weakening that coverage.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions