Access Control (RBAC)

Permission-gated routes by default - the scope.action model, withAuthGuard, and the guard hook.

thunder-core gates every route by permission out of the box. Permissions are scope.action strings, where:

  • scope is the router function name, and
  • action is the handler function name.

For example, a createCRUD router exposes actions like create, get, count, update, and del. A bare scope grants all actions in that router; * grants everything.

This is why thunder-core (and Thunder generally) requires named functions for routers and handlers - the names are the permission identifiers.

Data Model

Read the schema files for exact fields:

SchemaDescription
schemas/accessControlPolicy.tsA named bundle of permission strings.
schemas/accessControl.tsMaps a role to policies, with subRoles (roles this role may assign to invitees) and skipIntersection.

When a role resolves to multiple access-control documents, their policy sets are intersected unless skipIntersection is set.

Checking Permissions - withAuthGuard

import { withAuthGuard } from "@plugins/Huruf-Tech/thunder-core/utils/withAuthGuard.ts";

const { role, subRoles, policies, allow } = await withAuthGuard(req);

if (!allow("tenants", "create")) throw Response.forbidden();

withAuthGuard:

  • Resolves the effective role for the request (defaults to guest when unauthenticated).
  • Applies tenant role cascading (the parent inviter's role, unless RBAC_NO_CASCADE).
  • Loads the relevant policies, further narrowed by OAuth / API-key scopes unless they include * or full_access.
  • Returns an allow(scope, action) predicate.

Two behaviours worth knowing

The whole access-control and policy set is loaded once and cached for 30 seconds (via core/utils/cache.ts). A policy or role edit takes up to 30s to take effect - so if a permission change "didn't work", wait half a minute before debugging it.

Scope narrowing by name. When the resolved role holds the root policy (["*"]), the requested scopes are treated as policy names and resolved directly - so a root role with scopes: ["iam"] gets exactly the iam policy. For non-root roles, scopes only filter the policies the role already has.

The Guard Hook

hooks/guard.ts runs at very high priority (before everything). For every request it calls withAuthGuard and throws Response.forbidden(...) if allow(scope, name) is false. Every route is permission-gated by default based on the seeded policies.

The error names exactly what was missing, which makes this easy to diagnose:

missing permission tenants.create for role user and subRoles member

Because routes are denied unless a policy grants them, exposing a new route means adding its scope.action to a policy and attaching that policy to the appropriate role(s) - via the access-control routes, or by editing scripts/syncAccessControl.ts.

Default Roles & Policies

scripts/syncAccessControl.ts is the authoritative list - read it for the current state. At the time of writing:

PolicyPermissions
publicindex, home, wellKnown, auth, oauth, me.get
developeroauthClients, apiKeys
iamtenants, tenantMembers, tenantInvites, tenantMemberships.get, accessControlPolicies.myPolicies, accessControlPolicies.get
mediaimageKit.auth
root*
RolePoliciesNotes
guestpublic
userpublic, developer, iam, media
adminrootskipIntersection: true - an admin's invitee inherits the admin policy set (narrowed by their own scopes) instead of the intersection

The wallet routes are not in any default policy. Grant wallets / walletLedgers explicitly if you use them.

Granting a New Permission

Three ways, in order of preference:

Idempotent, and keeps the declaration next to the routes it covers:

import { ACManager } from "@plugins/Huruf-Tech/thunder-core/lib/accessControlManager.ts";

const policy = await ACManager.resolvePolicy("billing", ["billing.charge"]);
await ACManager.resolveRole("user", [policy]);

2. Edit the seed and re-run it

Edit scripts/syncAccessControl.ts, then:

deno task sync:ac:dev     # development
deno task sync:ac:prod    # production

Safe to re-run - see Setup & Lifecycle.

3. At runtime, via the management routes

  • routes/accessControl.ts - role to policy mappings
  • routes/accessControlPolicies.ts - policies; also exposes a "my policies" endpoint returning the caller's role / subRoles / policies

Remember the 30-second cache: a newly granted permission may take up to 30s to take effect.

The Access-Control Manager

ACManager (lib/accessControlManager.ts) is the programmatic API - it is what scripts/syncAccessControl.ts itself uses. Every method is upsert-based and safe to re-run:

import { ACManager } from "@plugins/Huruf-Tech/thunder-core/lib/accessControlManager.ts";

// Upsert a policy and $addToSet its permissions. Returns the policy document.
const billing = await ACManager.resolvePolicy("billing", ["billing.charge"]);

// Remove permissions from an existing policy ($pullAll; throws on failure).
await ACManager.denyFromPolicy("billing", ["billing.refund"]);

// Upsert a role and $addToSet policies. Accepts policy docs or policy NAMES
// (the name "*" attaches every policy). `opts` is applied on insert only.
await ACManager.resolveRole("cashier", [billing, "media"], {
  skipIntersection: true,
});

// Add assignable sub-roles to a role.
await ACManager.setSubRole("cashier", ["teller"]);
MethodFails by
resolvePolicy, resolveRole, setSubRolereturning null (guarded with .catch(() => null))
denyFromPolicythrowing

Called with no permissions or policies, resolvePolicy / resolveRole degrade to a plain findOne - handy for looking something up without creating it.


On this page