Guide

RBAC in SvelteKit + Supabase: one matrix, enforced twice

A permission system survives contact with a product only when the rule lives in one place and is enforced in more than one. This guide shows the shape we ship in our SvelteKit + Supabase Starter: three roles and eleven permissions in one static matrix, hierarchy as one strict inequality, enforced in application code on every mutating call and again in Supabase Row Level Security at the database.

Scope first: the application layer runs on top of Supabase, so this kit splits enforcement into two halves with different proofs. Code proves the business rules with an in-memory fake Supabase client; Row Level Security proves tenant isolation only against a real Supabase instance (supabase start). Where the shipped enforcement is narrower than the flat matrix, the gap is named below.

Three roles, eleven permissions

owner > admin > member, ranked 2 > 1 > 0. The full capability matrix lives in exactly one place — src/lib/server/rbac.ts — and looks like this:

Permissionowneradminmember
org.view
org.update
org.delete
members.view
members.invite
members.remove✓*
members.role.set✓*
invites.revoke
audit.view
billing.manage
ownership.transfer

* subject to the hierarchy rules below.

Two things matter about the matrix as data. It holds all eleven permissions and spans right to the billing.manage and ownership.transfer columns that only the owner row can grant — capabilities are not scattered across guards. And the owner row is an alias for the whole permission list, so adding a capability cannot forget the top role. UI gating is cosmetic only: every gate re-checks the matrix server-side.

Hierarchy rules — the part buyers get wrong

A matrix answers “may admins invite?” but not “may this admin demote that admin?”. Pairwise questions get numeric ranks and strict inequality. The rules are enforced in members.ts and invites.ts on every action:

  • Act downward only. mayActOn(actor, target) requires rank(actor) > rank(target). An admin cannot touch another admin or the owner.
  • Grant strictly below yourself. mayGrant(actor, granted) requires rank(actor) > rank(granted). Only owners mint admins; admins mint members. Nobody grants their own rank, ever.
  • No self-modification. You cannot change your own role, remove yourself via remove-member (use Leave), or transfer ownership to yourself.
  • Single-owner invariant. transferOwnership sets target → owner and actor → admin together; leaving while still the sole owner is refused with last_owner.

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

Enforcement: two independent layers

The kit refuses to trust a single layer. Application code: page loads call getOrgForUser() and get null unless the caller has a membership row; capability-gated reads re-check the matrix on top of membership. Actions re-read the membership fresh per request, then run requirePermission() plus hierarchy checks before any write. Forging requests with another user’s role field fails; only the session identity matters, and capabilities derive from database state.

Database layer (Row Level Security): the shipped migration enables RLS and scopes every read to is_org_member(org_id, auth.uid()). Owners and admins get memberships_update and invites_update; writes that application code refuses are also refused by policy, so a bug in one layer does not leak tenant data. The two layers verify each other — code proves behavior, a real Supabase instance proves policy.

// application layer — re-derive authority from fresh state, every action
await requirePermission(actorRole, 'members.role.set');
if (!mayGrant(input.actorRole, input.role)) {
	throw new MemberError('hierarchy_violation', `A ${input.actorRole} cannot grant the '${input.role}' role.`);
}

// database layer — RLS refuses the same write by policy
-- is_org_member(org_id, auth.uid()) scopes every read to the caller's orgs

Test coverage

tests/rbac.test.ts pins the matrix.tests/orgs-members.test.ts and tests/invites-seats.test.ts prove the hierarchy end-to-end against an in-memory fake Supabase client — including every attempt that must fail. tests/smoke.test.ts sanity-checks the rolling build. Assertions target machine codes, not prose, so copywriting editing an error string does not break the suite:

await expect(
	setMemberRole(sb as any, {
		orgId, actorUserId: admin.id, actorRole: 'admin',
		targetUserId: member.id, role: 'owner'
	})
).rejects.toMatchObject({ code: 'hierarchy_violation' });

The RLS policies themselves are verified only when running against a real Supabase instance (supabase start, detailed in the testing guide). That is an honest boundary, not a shortcut: “we wrote policies” is different from “the policies hold.”

Sharp edges we ship knowingly

  • The role column is TEXT validated in code. The schema does not declare a CHECK constraint or enum for role, so every entry point genuinely needs the type guard — the database will not reject an unknown role string for you. The migration’s RLS helpers do the same validation.
  • Admin role edits are hierarchy-capped, matrix-wide. The flat matrix grants admins members.role.set, but the hierarchy rules above restrict whom they may retarget — an admin cannot grant or demote to/from admin-rank on a same-rank peer. The matrix and the hierarchy are meant to be read together.
  • RLS depth depends on a running instance. The unit suite proves application-layer enforcement with the fake client; the database policies are real but unexercised until a Supabase instance is up. That split is surfaced, not hidden.

Production checklist

  • Model capabilities as a static matrix beside a typed permission union — typos become compile errors and drift becomes a reviewable diff.
  • Encode hierarchy as numeric ranks compared with strict greater-than: equal rank always fails, including self-actions.
  • Split the questions: can() for capability, mayActOn()/mayGrant() for who may touch whom.
  • Re-fetch the actor’s role fresh inside every mutating call — data-load-time roles are stale by submit time.
  • Enforce twice: once in application code for shaped responses, again in Row Level Security for tenant isolation. Neither layer trusts the other.
  • Guard the single-owner invariant: no self-modification, no self-removal, no orphaned organization.
  • Verify the RLS policies against a real Supabase instance — “we wrote policies” is different from “the policies hold”.

All seven lines are implemented in the starter kit. For the rest of the enforcement story, read the guides on architecture (where this sits in the stack) and seat billing (the one enforcement gate at invite acceptance).

Related reading

Get in touch

Questions about the product, team licenses, or anything else? We'll respond within 48 hours.

Max 2000 characters

Stored in our own database — no third party. Deleted on request.