Permissions
How the frontend reads CASL rules from /auth/me and gates UI and routes with Can, usePermission, and requirePermission.
Permissions in the Education Hub are enforced on the backend — the API is the source of truth, and the frontend can never grant access the server wouldn't. So why does the frontend care at all? Because a junior dev shouldn't render a "Delete student" button to someone who can't delete students, and shouldn't let a route load a page the user has no business seeing.
The frontend's whole job here is to mirror the backend's decision so the UI matches reality. It does that with CASL — the same rule engine the API uses — fed the exact rules the server sends back. The engine itself lives behind the API (see Auth & Permissions and RBAC & Scopes); this page is only about the React side.
Where rules come from
When the app calls /auth/me, the response carries everything the frontend needs to reason about access:
rules— a CASL MongoDB-style rule array. Each rule is an{ action, subject }pair that may also carryconditionslike{ campusId }for instance-scoped access.user.type— the broad bucket:guest,student,guardian,staff, orplatform_admin.roles— named roles likeadmin, used for role-based checks.
You almost never touch this DTO directly. It flows through one function that turns it into a live ability.
Building the ability
buildAbility(me) is the single place the /auth/me payload becomes a CASL AppAbility. It tries three things in order, so the frontend always ends up with some ability — never a crash.
function buildAbility(me: MeData | null | undefined): AppAbility {
if (!me) return createMongoAbility<AppAbility>([])
// 1. Rules present? Use them directly — conditions included.
if (me.rules && Array.isArray(me.rules) && me.rules.length > 0) {
return createMongoAbility<AppAbility>(me.rules as ...)
}
// 2. Fallback: flat permissions -> rules without conditions.
const rules = me.permissions.map((p) => ({ action: p.action, subject: p.resource }))
return createMongoAbility<AppAbility>(rules)
}The order matters. Prefer rules because only they carry conditions — the flat permissions fallback is a coarser, condition-free safety net. And no me means an empty ability: zero permissions, deny everything.
In the React tree, you don't call buildAbility yourself. The PermissionProvider reads useMe(), builds the ability once, and memoizes it — so every component below it shares the same instance through context.
Fail-closed, with one exception
The default is always deny — an empty ability, an unmatched rule, a missing subject all resolve to "no". The one deliberate exception: while auth is still loading, checks are treated as allowed. That avoids a jarring flash of denied UI that flips to allowed a moment later once the user resolves.
Three ways to check
Once the ability exists, there are exactly three tools for asking "can this user do X?" Each fits a different job — declarative UI, imperative logic, and route protection.
Can — gate the UI
<Can> is the declarative show/hide. Wrap any JSX in it with an action and resource, and the children render only if the check passes. Pass a fallback for the denied case, or leave it out and the children simply vanish.
<Can action={Action.READ} resource={Resource.CLASS}>
<Button>View classes</Button>
</Can><Can> accepts more than permissions — you can also require a role or a userType, and every check you supply must pass (logical AND). Supply nothing for a check and it's treated as satisfied.
usePermission — check in logic
When you need the answer inside an event handler, a useMemo, or any branching logic, reach for the usePermission() hook. It hands you can, hasRole, and isUserType.
const { can } = usePermission()
const canEdit = can(Action.UPDATE, Resource.STUDENT)
// pass a subject object to evaluate instance conditions:
const canEditThis = can(Action.UPDATE, Resource.STUDENT, studentRecord)can is the same engine <Can> uses under the hood — <Can> is really just this hook with JSX wrapped around it.
requirePermission — guard a route
The first two run inside a rendered component. requirePermission runs before the route even loads. It's a TanStack Router beforeLoad guard: it fetches the user, runs your checks, and redirects to /403 on the first failure (or to / when there's no user at all).
export const Route = createFileRoute('/staff/students')({
beforeLoad: requirePermission({ action: 'read', resource: 'student' }),
// ...
})Like <Can>, it accepts any combination of action/resource, role, roles, roleIncludes, and userType — all combined with AND. This is your fail-closed front door: if the guard isn't satisfied, the page never renders.
Guards run on the cache, not a fresh fetch
requirePermission reads the user via queryClient.ensureQueryData(AuthOptions.getMe()) and builds a fresh ability with buildAbility(me) — it doesn't use the React context (there's no rendered tree yet during beforeLoad). The guard is a backstop for navigation; the real enforcement is still the API.
Actions and resources
You never type permission strings by hand. Both halves are typed enums in permissions.types.ts, derived from the generated OpenAPI contracts — so a typo is a compile error, not a silent always-deny.
const Action = {
READ: 'read', LIST: 'list', CREATE: 'create',
UPDATE: 'update', DELETE: 'delete', MANAGE: 'manage',
} as const
const Resource = {
STAFF: 'staff', STUDENT: 'student', CLASS: 'class',
ROLE: 'role', CAMPUS: 'campus', CBT_EXAM: 'cbt_exam', /* ...and more */
} as constMANAGE is CASL's wildcard action — it covers every action on a resource. Reach for Action and Resource everywhere; importing the constant keeps you honest about what actually exists.
Conditions
Some rules aren't blanket grants — they're instance-scoped. A rule might say "update class, but only where { campusId: 42 }". A type-level check (can(UPDATE, CLASS)) can't answer that; CASL needs the actual object to match conditions against.
That's what a subject is. You hand the check the data object, and CASL evaluates the rule's conditions against it. For panels and surfaces, useMeetsRequirement does this through a subject resolver — a small function that pulls the object together from params, data, and me.
requirePermission({
action: 'read',
resource: 'class',
subject: ({ params }) => ({ campusId: params.campusId }),
})A declared subject must resolve — or it's denied
When a subject resolver is present, the check is intentionally instance-level. If the resolver returns nothing (auth not ready, missing param), the code fails closed rather than quietly falling back to a type-level check that would ignore the condition entirely. A subject you can't build is an access you don't get.
Where to go next
User types
The five user-type buckets that userType checks read against.
Auth & Permissions (API)
The backend CASL engine and how /auth/me builds its rules.
RBAC & Scopes (API)
Where roles, conditions, and instance scopes come from server-side.
Sessions & tokens
How the frontend gets the authenticated session behind /auth/me.