Guide
RBAC in SvelteKit: one matrix, one rank rule
Role checks start life as a single if. A release or two later there are forty of them across routes and components, three spellings of “admin”, and nobody can say what a member may actually do. The fix is not more checks — it is fewer places that can be wrong. This guide shows the shape we ship in our multi-tenant SvelteKit starter: capabilities as one static matrix, hierarchy as one strict inequality, both enforced server-side on every request, and a test suite that tries to break each boundary by name.
Scope first: this is application-layer RBAC for a single-database starter — three roles (owner, admin, member) and nine permissions, checked against fresh database state on every load and every form action. It is not a policy-engine pitch, and where the shipped enforcement is narrower than the matrix, the gap is named below rather than smoothed over.
Why scattered string checks rot
Hand-rolled permission systems fail in four repeating ways:
- The same rule lives in many files. “Admins can invite” gets restated in a route guard, a component, and a service. When the rule changes, one copy is always missed.
- Role names are free strings.
'Admin','admin', and'OWNER'all look plausible in a diff; only a comparison against a closed list can tell them apart. - The UI is the gate. Hiding a button feels like enforcement until someone POSTs the form directly.
- Nobody can state who may grant what. Promotion rules live in whoever wrote that endpoint’s head at the time.
A static matrix dissolves the first three by construction and makes the fourth explicit data. What it does not solve is context: whether an admin may act on this particular member depends on ranks and rows, so pairwise rules need their own home — which is exactly how the shipped module splits it.
The capability matrix as data
Roles and permissions are declared once, as typed constants:
export const ROLES = ['owner', 'admin', 'member'] as const;
export type Role = (typeof ROLES)[number];
export const PERMISSIONS = [
'org.view',
'members.view',
'members.invite',
'members.remove',
'members.role.set',
'invites.revoke',
'audit.view',
'billing.manage',
'ownership.transfer'
] as const;
export type Permission = (typeof PERMISSIONS)[number];And the entire policy fits in one record:
/**
* Static capability matrix — enforced server-side on every load/action.
* Hierarchy rules (who may act on whom) live in roleRank()/mayActOn() below
* and are applied by the membership service, not by the UI.
*/
const MATRIX: Record<Role, readonly Permission[]> = {
owner: PERMISSIONS,
admin: [
'org.view',
'members.view',
'members.invite',
'members.remove',
'members.role.set',
'invites.revoke',
'audit.view'
],
member: ['org.view', 'members.view']
};
export function can(role: Role, permission: Permission): boolean {
return MATRIX[role].includes(permission);
}Three details carry most of the value. The docblock states the contract outright: the matrix is enforced server-side, applied by the membership service, never by the UI. The owner row is an alias for the whole permission list, so adding a capability cannot forget the top role. And because can() accepts only the Permission union, a misspelled permission is a compile error, not a check that silently returns false forever. Reading a member’s two permissions tells you their whole world: view the org, view the members. Nothing else.
Ranks: act and grant strictly downward
A matrix answers “may admins invite?” but not “may this admin promote that admin?”. Pairwise questions get numeric ranks and one strict rule:
const RANK: Record<Role, number> = { member: 0, admin: 1, owner: 2 };
/** Actors may only act on targets strictly below their own rank. */
export function mayActOn(actor: Role, target: Role): boolean {
return RANK[actor] > RANK[target];
}
/** Granted role must be strictly below the actor's rank (no self/equal grants). */
export function mayGrant(actor: Role, granted: Role): boolean {
return RANK[actor] > RANK[granted];
}The interesting cases are all consequences of strictness. Equal rank fails everywhere: an admin cannot demote another admin, and an owner cannot act on an owner — including through a form targeting themselves, which the service layer converts into its own explicit errors (“You cannot change your own role”). Upward action fails by definition since nothing outranks owner. Grants follow the same predicate, so admins mint members, owners mint admins, and no input field can manufacture an owner except from an owner. The invite service leans on exactly this at creation time — a granted role must pass mayGrant before any token exists, so an owner-level invite link can only ever originate from an owner.
Where enforcement happens per request
The org layout resolves membership on every request — one inner join returns the org plus the caller’s current role, or null, which becomes a 404 before any child page renders. Then the page load recomputes everything the UI will need from that fresh role:
const membership = await getMembership(db, params.id, locals.user!.id);
const role = membership?.role as Role;
const [members, invites, seatsUsed, sub] = await Promise.all([
listMembers(db, params.id),
can(role, 'members.invite') ? listPendingInvites(db, params.id) : Promise.resolve([]),
countActiveSeats(db, params.id),
billing.getSubscriptionState(params.id)
]);
const permissions = Object.fromEntries(PERMISSIONS.map((p) => [p, can(role, p)])) as Record<
Permission,
boolean
>;Two different jobs hide in there. The conditional query keeps data the caller cannot use from ever being fetched; the permissions map drives rendering only — buttons appear or disappear, and none of it is trusted later. Mutations take the second, harder path. Every form action begins by re-fetching the caller’s role from the database, under a comment worth copying verbatim into your own code:
/** Re-fetches the caller's current role on every action — never trust data-load-time roles. */
async function requireRole(orgId: string, userId: string) {
const db = getDb();
const found = await getOrgForUser(db, userId, orgId);
if (!found) {
throw new OrgError('not_found', 'Organization not found (or you are not a member).');
}
return found;
}The role a user had when the page rendered is stale by the time they submit — a demotion in another tab, a revocation minutes ago. So each action passes the freshly loaded actorRole down into the service, which enforces the matrix a second time:
requirePermission(input.actorRole, 'members.role.set');
if (!isRole(input.role)) throw new RbacError('bad_role', `Unknown role '${input.role}'.`);
if (input.targetUserId === input.actorUserId) {
throw new MemberError('hierarchy_violation', 'You cannot change your own role.');
}
const target = await getMembershipRow(db, input.orgId, input.targetUserId);
if (!target) throw new MemberError('no_membership', 'That user is not a member of this organization.');
const targetRole = target.role as Role;
if (!mayActOn(input.actorRole, targetRole)) {
throw new MemberError('hierarchy_violation', `A ${input.actorRole} cannot act on a ${targetRole}.`);
}
if (!mayGrant(input.actorRole, input.role)) {
throw new MemberError('hierarchy_violation', `A ${input.actorRole} cannot grant the '${input.role}' role.`);
}Note what the service does with the form-supplied target role: it runs isRole — a type guard over the closed role list — then mayGrant, regardless of what the dropdown offered. The UI shapes choices; the server decides. That ordering also means the route’s permission map and the service’s checks can disagree safely: the service always wins.
Typed failures become precise HTTP
Denial is not one error. The module distinguishes five failure codes, carried on a typed error class:
export class RbacError extends Error {
constructor(
public code: 'forbidden' | 'no_membership' | 'hierarchy_violation' | 'last_owner' | 'bad_role',
message: string
) {
super(message);
}
}
export function requirePermission(role: Role | null | undefined, permission: Permission): void {
if (!role || !can(role, permission)) {
throw new RbacError('forbidden', `Role '${role ?? 'none'}' lacks permission '${permission}'.`);
}
}One mapper turns those codes into responses, so protocol behavior is uniform no matter which service threw:
const status =
anyE.code === 'forbidden' || anyE.code === 'hierarchy_violation' || anyE.code === 'no_membership'
? 403
: anyE.code === 'not_found'
? 404
: 400;
return fail(status, { error: anyE.message });Authorization failures are 403, absence is 404, and anything the user can fix by changing their input is 400. Success paths that redirect stay safe too: the mapper re-throws SvelteKit’s redirect control-flow object instead of converting a completed action into a 500 after its side effects already committed — a failure mode with its own test case in the suite.
The last-owner invariant
Hierarchy alone cannot protect an organization from its own settings screen: if the final owner leaves or is removed, the org is orphaned with nobody able to administer it. The shipped services close that hole from three directions. Removal is structurally impossible against an owner — mayActOn(anything, 'owner') is false because nothing outranks owner. Self-removal is refused with its own code and pointed at the correct door (“Use leave organization to remove yourself”). And leaving counts owners first:
if (membership.role === 'owner') {
const all = await db.select({ userId: memberships.userId, role: memberships.role }).from(memberships).where(eq(memberships.orgId, input.orgId));
if (countOwners(all) <= 1) {
throw new MemberError('last_owner', 'Transfer ownership before leaving the organization.');
}
}Transfer is the sanctioned exit, and it encodes a stricter invariant than “at least one owner” — the code expects exactly one and treats more as corruption:
if (countOwners(all) > 1) throw new MemberError('last_owner', 'Organization has multiple owners; data inconsistency detected.');
await db.update(memberships).set({ role: 'owner' }).where(eq(memberships.id, target.id));
await db
.update(memberships)
.set({ role: 'admin' })
.where(and(eq(memberships.orgId, input.orgId), eq(memberships.userId, input.actorUserId)));Target up to owner, actor down to admin, one audit entry recording the swap — and a test asserting the exchange happens exactly once, plus another pinning that only owners may invoke transfer at all. The count happens inside the same service call as the writes, which is the only place a check like this survives refactoring.
Testing RBAC like an attacker
The suite attacks the policy on two tiers. Pure unit tests enumerate the matrix and the rank table directly — six cases in rbac.test.ts covering owner-has-everything, admin’s exact boundary (members.invite yes, billing.manage no), member-view-only, all seven pairwise act-on combinations, grants strictly downward, and typed throws:
it('actors act only on strictly lower ranks', () => {
expect(mayActOn('owner', 'owner')).toBe(false);
expect(mayActOn('owner', 'admin')).toBe(true);
expect(mayActOn('owner', 'member')).toBe(true);
expect(mayActOn('admin', 'admin')).toBe(false);
expect(mayActOn('admin', 'owner')).toBe(false);
expect(mayActOn('admin', 'member')).toBe(true);
expect(mayActOn('member', 'member')).toBe(false);
});Service-level tests then seed real users in all four positions — owner, admin, member, outsider — and try each boundary through the public API, asserting machine codes rather than prose:
await expect(
setMemberRole(db, { orgId, actorUserId: admin.id, actorRole: 'admin', targetUserId: member.id, role: 'owner' })
).rejects.toMatchObject({ code: 'hierarchy_violation' });- Equal-rank action refused (admin → admin), upward grant refused (admin mints no owner).
- Members hold no role-management power at all — plain
forbidden. - Removal ladder holds: admins remove members, never other admins or the owner.
- Self-removal routed to
self_remove; outsiders getno_membershipeven for leaving. - Last owner blocked from leaving; transfer swaps roles exactly once and unlocks departure afterward.
Seventeen service-level cases in orgs-members.test.ts cover org creation, role management, removal, leaving, transfer, and the audit entries they write. Asserting on code, not message text, is what lets copywriters edit error strings without breaking the suite.
Sharp edges we ship knowingly
- A read route once trusted membership more than the matrix does. The matrix reserves
audit.viewfor admins and owners — but our own audit subpage’s load checked org membership only, and its nav link rendered for every role: a member could read the log via direct URL while every test stayed green, because no test drove a member at that route. We found it by writing this guide against the routes instead of the intentions, and closed it the same day — the load now runsrequirePermission(role, 'audit.view')(403 below admin) and the link renders only for roles holding the permission. The lesson stands regardless of the fix: declaration is not enforcement until each surface names its own check. - Ownership transfer issues two sequential UPDATEs with no explicit transaction wrapper in the shipped code — safe in practice on the starter’s single-process SQLite setup, and the first thing to wrap in
db.transaction()when you move to Postgres. - The role column is TEXT validated in code. The schema comment points at the RBAC module rather than declaring a CHECK constraint or enum, so every entry point really does need the
isRoleguard — the database will not reject an unknown role string for you.
Production checklist
- Model capabilities as a static Record<Role, Permission[]> beside a typed permission union — typos become compile errors and drift becomes a reviewable diff.
- Define the top role as the whole permission list, so a newly added capability reaches it without a second edit.
- Encode hierarchy as numeric ranks compared with strict greater-than: equal rank always fails, including self-actions.
- Split the questions: can() answers “may this role do this at all”, mayActOn()/mayGrant() answer “may this actor touch this target”.
- Re-fetch the actor’s role from the database inside every form action — the role from data-load time is stale by submit time.
- Enforce twice: once in the route for shaped responses, again in the service that owns the write. Neither layer trusts the other.
- Validate client-supplied role strings with a type guard before any hierarchy math runs on them.
- Map typed error codes to HTTP statuses in one place — denial codes to 403, absence to 404, user-fixable mistakes to 400.
- Count owners before any destructive membership operation; an organization stranded with zero owners is a support ticket you wrote yourself.
- Pin every boundary with a test that asserts the typed error code. Message strings get edited; codes don’t.
All ten lines are implemented, not aspirational, in the starter kit. For the surrounding machinery, read the earlier guides on application-layer multi-tenancy and single-use invite links — invite creation and revocation are the paths where this matrix and hierarchy meet untrusted input first.
Related reading
- Multi-tenant Authentication — application-layer tenancy patterns
- Invite Link Flow — where roles are first assigned
- Audit Log — records every role change
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll respond within 48 hours.