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.
Summary
No route under
/adminhas a route-level authorization check._app/route.tsx'sbeforeLoadverifies 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/brandingrender fully, with a working "Save" / "Add User" UI, for bothSTANDARDandGROUP_MANAGER./admin/groups,/admin/instrument-reposdo the same forGROUP_MANAGER(STANDARDgets a 403 crash screen on these two, because their loaders read endpoints it holds no rule for)./group/managerenders and submits forSTANDARD, which holds onlyreadonGroup./admin/audit/logsis the only one blocked for both roles, because its loader is gated onmanage 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:
/admin/usersPOST/PATCH/DELETE /v1/users403/admin/settings,/admin/brandingPATCH /v1/setup403/admin/instrument-reposPOST /v1/instrument-repos403/admin/audit/logsGET /v1/audit/logs403/admin/groups,/group/managePATCH/DELETE /v1/groups/:id/admin/groups/createPOST /v1/groupsGROUP_MANAGER— separate issueReads are row-scoped the same way, so the populated tables are not a leak:
GET /v1/usersreturns only the acting user's own group for aGROUP_MANAGER, and only their own account for aSTANDARDuser.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 - Forbiddencrash screen, rather than a redirect./dashboardalready does the nicer thing — an explicitbeforeLoad/loaderredirect to/session/start-sessionfor a role that should not be there. Admin routes should follow that pattern.Suggested fix
A shared
beforeLoadon the/adminlayout route that reads the acting role from the store (useAppStore.getState(), as_app/route.tsxdoes) and redirects rather than rendering. Related: #1355.Notes
Found while writing the Playwright suite (branch
e2e-tests).testing/src/specs/authorization.spec.tsnow 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.