Naalya Handbook

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 carry conditions like { campusId } for instance-scoped access.
  • user.type — the broad bucket: guest, student, guardian, staff, or platform_admin.
  • roles — named roles like admin, 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.

src/lib/permissions/permissions.ability.ts
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.

example: gating a button
<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.

example: imperative check
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).

example: a route guard
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.

src/lib/permissions/permissions.types.ts
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 const

MANAGE 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.

example: an instance-scoped requirement
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

On this page