Skoolsewa - Ecommerce Docs
Developer Resourcesauth

Admin Route Guarding

How the admin panel decides which pages a role may open — the server guard, the exemption list, the denial and outage destinations, and the failure modes each one exists to prevent.

Admin Route Guarding

How skoolsewa-frontend enforces per-route authorization, and why it is built this way. The backend remains the only enforcement that matters; everything here decides what a browser is allowed to render.

1. Documentation Evidence

Source TypeFilesWhat Was Extracted
Frontend controllib/auth/require-permission.ts, lib/auth/access.ts, lib/auth/route-exemptions.tsThe guard contract, the outcome table, the exemption list
Backend guardapps/api/src/common/authorization/role.guard.tsThe superadmin rule, the 403 error codes, the admin-surface selector
Backend endpointsapps/api/src/modules/auth/auth-session.controller.tsWhat /auth/me and /auth/permissions return
VerificationBrowser rounds 1 and 2, plus tests/lib/auth/page-guard-audit.test.tsObserved behaviour per role, and the invariant that keeps it true

2. Summary

FieldValue
Where the decision is maderequirePermission(code, from), called as the first statement of each gated page.tsx
What it returnsPromise<void>. Every non-authorized outcome is a redirect() — there is no result to inspect, and none to forget to inspect
What it readsGET /api/auth/permissions for the effective permission list; GET /api/auth/me for requiresRoleSelection
Fail directionClosed. Omitting the call is a failing build, not an ungated page
Enforcement of recordThe backend, on every request, via RoleGuard and @Permissions()

3. Why the guard is per-page and not in the layout

Three independent reasons, each fatal to a layout-level check on its own.

  1. A layout has no pathname. An App Router layout.tsx receives { children }. There is no route to resolve a permission for.
  2. Supplying one from the edge is forgeable. proxy.ts skips any request carrying next-router-prefetch. An attacker sends that header plus a forged x-pathname naming an exempt route, and a layout guard resolves null and renders the page.
  3. A layout does not re-run on soft navigation. Under partial rendering a shared layout is preserved between sibling routes, so a layout guard fires on hard load and then essentially never again.

A page-level call takes a literal, so there is nothing to forge, and pages re-render on every navigation.

4. Outcomes

SituationBehaviour
Permission heldreturns; the page renders
Permission absent, role activeredirect("/dashboard/no-access?from=…")
Roles held, none activeredirect("/dashboard/select-role") — carries a sign-out control
No role heldredirect("/dashboard/no-access")
Session invalid (401)redirect("/auth/sign-out")never /login directly
Timeout, 5xx, unreachable, 404, missing API_URLredirect("/dashboard/service-unavailable")

An error is not a denial

The distinction between the last two rows is the most important thing in this document. A failed permission read yields an empty list, which is indistinguishable from a legitimately unprivileged user if the two are collapsed. Collapsing them means that during a backend outage every user in the product is told they lack access — an outage rendered as a permissions bug, which is the most expensive possible misdiagnosis.

The same rule applies on the client: usePermissions exposes isError, and the sidebar renders a degraded state rather than zero rows.

Why 401 does not redirect to /login

proxy.ts redirects any request carrying an access token away from /login and back to /dashboard. A present-but-invalid token would therefore bounce between the two forever. Signing out clears the cookies the proxy keys on, which is what breaks the cycle — and /auth/sign-out is itself in PUBLIC_ROUTES, or signing out a second time loops in the same way.

5. The superadmin rule

Keyed on activeRole.isSuperadmin, never on a role's name. role.name is a mutable text column, so a name comparison means renaming any role to the literal string superadmin grants universal access.

The frontend does not read the flag at all. GET /auth/permissions already returns the entire catalogue for a superadmin active role, so the permission list alone is the whole decision, and a second flag check would be a second source of truth. canAccess keeps the parameter so it states the whole rule; both call sites pass false.

Permissions resolve from the active role only, never the union of roles held. A teacher who is also a parent, viewing as Guardian, must not carry staff permissions.

6. The exemption list

lib/auth/route-exemptions.ts names every route that renders without a permission. Adding an entry ungates a live route for every user, so the list is:

  • a production module, not a constant in a test file — ungating a route must not be a one-line edit to a test that keeps the suite green;
  • matched exactly, never by prefix/dashboard is on the list, so a prefix rule exempts the entire product;
  • pinned by size, so growth fails the suite until someone updates the count deliberately.

The guard's own destinations — no-access, select-role, service-unavailable — must be exempt or they redirect to themselves.

7. The invariant that keeps this true

tests/lib/auth/page-guard-audit.test.ts walks app/ from the filesystem and asserts, for every page outside a declared unguarded tree:

AssertionFailure it prevents
the guard is calleda new page ships ungated
the call is awaiteda floating promise: the redirect never fires and the page renders
it is the first statementa guard that runs after a fetch has already read the row it protects
it is not inside try/catchredirect() throws NEXT_REDIRECT; a catch swallows it
from matches the routethe denial page and the audit log name the wrong path

Routes are derived from the filesystem, never listed, and comments are stripped before matching — a commented-out call once satisfied every assertion in the file.

8. Failure modes worth knowing

FailureWhy it is invisible to the usual gates
A client component that conditionally drops {children} swallows a server-side redirect. The layout shell streams first, so a page's NEXT_REDIRECT arrives inside the RSC payload rather than as a 307 — and that payload is children.The guard logs the denial, the route answers 200, and typecheck, lint, tests and the build are all green. Only a low-privilege session on a forbidden URL in a real browser shows it.
Fetching a permission-gated endpoint for every user. /api/admin/permissions 403s for anyone without Users_READ.The failure is swallowed into an empty array whose only consumer is a screen those users cannot reach. Visible only in the browser console.
A permission-set change makes can("Module_ACTION") false for everyone, so a retained control renders its missing-permission branch forever.The argument is a string literal: no compile error, no test, no backend gate.

9. Not applicable

No backend module, controller, service, schema change, queue job or realtime event belongs to this feature — it is a frontend control over endpoints that already existed. The API and Backend documents for this section describe the authentication surface it consumes.