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 Type | Files | What Was Extracted |
|---|---|---|
| Frontend control | lib/auth/require-permission.ts, lib/auth/access.ts, lib/auth/route-exemptions.ts | The guard contract, the outcome table, the exemption list |
| Backend guard | apps/api/src/common/authorization/role.guard.ts | The superadmin rule, the 403 error codes, the admin-surface selector |
| Backend endpoints | apps/api/src/modules/auth/auth-session.controller.ts | What /auth/me and /auth/permissions return |
| Verification | Browser rounds 1 and 2, plus tests/lib/auth/page-guard-audit.test.ts | Observed behaviour per role, and the invariant that keeps it true |
2. Summary
| Field | Value |
|---|---|
| Where the decision is made | requirePermission(code, from), called as the first statement of each gated page.tsx |
| What it returns | Promise<void>. Every non-authorized outcome is a redirect() — there is no result to inspect, and none to forget to inspect |
| What it reads | GET /api/auth/permissions for the effective permission list; GET /api/auth/me for requiresRoleSelection |
| Fail direction | Closed. Omitting the call is a failing build, not an ungated page |
| Enforcement of record | The 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.
- A layout has no pathname. An App Router
layout.tsxreceives{ children }. There is no route to resolve a permission for. - Supplying one from the edge is forgeable.
proxy.tsskips any request carryingnext-router-prefetch. An attacker sends that header plus a forgedx-pathnamenaming an exempt route, and a layout guard resolvesnulland renders the page. - 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
| Situation | Behaviour |
|---|---|
| Permission held | returns; the page renders |
| Permission absent, role active | redirect("/dashboard/no-access?from=…") |
| Roles held, none active | redirect("/dashboard/select-role") — carries a sign-out control |
| No role held | redirect("/dashboard/no-access") |
| Session invalid (401) | redirect("/auth/sign-out") — never /login directly |
Timeout, 5xx, unreachable, 404, missing API_URL | redirect("/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 —
/dashboardis 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:
| Assertion | Failure it prevents |
|---|---|
| the guard is called | a new page ships ungated |
the call is awaited | a floating promise: the redirect never fires and the page renders |
| it is the first statement | a guard that runs after a fetch has already read the row it protects |
it is not inside try/catch | redirect() throws NEXT_REDIRECT; a catch swallows it |
from matches the route | the 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
| Failure | Why 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.
Auth Module Feature Guide
Functional behavior of admin auth and customer mobile auth — login, sessions, password reset, and verification.
My Profile
The self-profile surface — what a person sees about their own record, which blocks appear for which person type, and the two categories of data deliberately excluded.