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:
scopeis the router function name, andactionis 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:
| Schema | Description |
|---|---|
schemas/accessControlPolicy.ts | A named bundle of permission strings. |
schemas/accessControl.ts | Maps 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
guestwhen unauthenticated). - Applies tenant role cascading (the parent inviter's role, unless
RBAC_NO_CASCADE). - Loads the relevant policies, further narrowed by OAuth / API-key
scopesunless they include*orfull_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 memberBecause 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:
| Policy | Permissions |
|---|---|
public | index, home, wellKnown, auth, oauth, me.get |
developer | oauthClients, apiKeys |
iam | tenants, tenantMembers, tenantInvites, tenantMemberships.get, accessControlPolicies.myPolicies, accessControlPolicies.get |
media | imageKit.auth |
root | * |
| Role | Policies | Notes |
|---|---|---|
guest | public | |
user | public, developer, iam, media | |
admin | root | skipIntersection: 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:
1. ACManager from your own code (recommended)
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 # productionSafe to re-run - see Setup & Lifecycle.
3. At runtime, via the management routes
routes/accessControl.ts- role to policy mappingsroutes/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"]);| Method | Fails by |
|---|---|
resolvePolicy, resolveRole, setSubRole | returning null (guarded with .catch(() => null)) |
denyFromPolicy | throwing |
Called with no permissions or policies, resolvePolicy / resolveRole degrade to a plain findOne - handy for looking something up without creating it.