Authorization
How the app decides who may do what: roles and permissions, plan entitlements, step-up checks, and the two layers that enforce them.
Authentication answers "who are you?". Authorization answers the next two questions: may you act here at all, and may you perform this specific action. Every screen in the generated app and every table in its database answers those questions the same way, and this page is the map of that system.
The mental model
Users belong to organizations. A membership carries roles. Roles map to permissions. Attributes, such as whether the membership is still active or whether you are acting on yourself, refine the final answer. If you can hold that chain in your head, everything below is a detail of it.
The design is attribute-based access control (ABAC): a decision can consult any attribute of the user and the thing being acted on. Roles are the most important attribute, so day to day it feels like classic role-based access, but nothing limits a rule to roles, and the last section shows a rule that never mentions one.
Two layers, one answer
Every check exists twice, on purpose:
- The app checks first, as a courtesy. A synchronous
user.can(...)call decides whether a button, tab or menu item is even shown. Its only job is a good experience: nobody should see controls they are not allowed to use. - The database checks again, as the authority. Every query runs through row-level security, which re-evaluates the same rules server-side. This is the layer that actually protects data.
A tampered or buggy client can skip the first layer; it can never skip the second. That is why the docs keep repeating the same sentence in different forms: hiding a button is a courtesy, row-level security is the law.
Both layers fail closed around the same attribute: a deactivated membership resolves to zero roles. The app stops offering anything, and the database stops matching any permission, without either side needing a special case.
Roles and permissions
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
Inside an organization, what a member may do is expressed as dotted
permission slugs such as billing.manage or invites.create. Roles are
named bundles of those slugs, connected by a plain join table, so changing
what a role can do is a data change, not a code change.
Slugs support wildcards on the granted side: a role granted invites.* holds
invites, invites.create, and any deeper descendant, and a role granted
* holds everything. Checks always name a concrete slug; the wildcard lives
in the grant, never in the question.
The three seeded roles hold twelve permissions between them:
| Permission | What it unlocks | Owner | Admin | Member |
|---|---|---|---|---|
orgz.update | Edit the organization's name, slug, logo | ✓ | ||
memberships.read | See the member list | ✓ | ✓ | ✓ |
memberships.update | Edit, deactivate and reactivate members | ✓ | ✓ | |
memberships.delete | Remove members | ✓ | ✓ | |
usersroles.read | See who holds which role | ✓ | ✓ | ✓ |
usersroles.update | Change a member's role | ✓ | ✓ | |
invites.create | Invite people, resend invitations | ✓ | ✓ | |
invites.read | See pending invitations | ✓ | ✓ | |
invites.revoke | Revoke invitations | ✓ | ✓ | |
users.read | View member profiles | ✓ | ✓ | ✓ |
billing.read | See the plan's public summary | ✓ | ||
billing.manage | Manage the subscription and seats | ✓ | ✓ |
The seed defines thirteen more slugs that no role holds, including every
wildcard and slugs like memberships.create and invites.update. They are
ready-to-grant vocabulary for roles you add yourself, not dead weight.
Two grants deserve a second look, because both are traps for new policies:
billing.read belongs to members only
The owner and admin read billing through billing.manage; every shipped
read policy is written as billing.read OR billing.manage. A new policy
gated on billing.read alone would lock out exactly the people who run the
organization. Keep the pair together.
orgz.delete is granted to nobody
Organization deletion works anyway, through an explicit owner-role check.
If you build on orgz.delete, grant it first; checking it today always
answers no.
Ownership itself is stricter than any permission: every organization has
exactly one active owner, the database fails any transaction that would leave
zero or two with OWNER_INVARIANT, and the owner role cannot be handed out
through ordinary role edits. It moves only through the
ownership transfer handshake.
The authoritative list of roles, permissions and grants is one seed file, short enough to read in a sitting, and editing it is how you customize the system:
INSERT INTO public.roles (slug, name, description) VALUES
('owner', 'Owner', 'Organization owner with full administrative privileges');
-- ...
INSERT INTO public.roles_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM public.roles r, public.permissions p
WHERE r.slug = 'admin'
AND p.slug IN (
'billing.manage',
'invites.create',
-- ...
);It is a seed, not a migration
Locally, supabase db reset replays migrations and then the seeds; see
Rebuild the database. Pushing
migrations to a hosted project runs none of that: the roles and
permissions tables come up empty, and the first signup fails with Owner role not found when the database tries to create their default
organization. Run this file yourself in the SQL editor when provisioning a
hosted project.
One more deliberate choice worth knowing: the catalogue tables are readable
by every signed-in user. Hiding the vocabulary would add nothing, because
enforcement lives in row-level security, not in secrecy; a curious user
learning that billing.manage exists still cannot use it.
Adding a role
The seeded trio is a starting set, not a ceiling. Because roles are rows, not an enum, the database side of a new role is one seed edit; the app side is one Dart enum entry, and the compiler walks you through everything that entry touches.
Already deployed? Ship a migration instead
Every SQL edit below writes directly into the shipped seed and migration files, which is right up until your first deploy. A live project replays neither: wrap the same statements in a new migration, and rerun the seed statements in the SQL editor as the callout above describes.
Seed the role and its grants. One INSERT creates the role, one grants its permissions; both follow the shape the file already uses:
INSERT INTO public.roles (slug, name, description) VALUES
('auditor', 'Auditor', 'Read-only access for compliance review');
INSERT INTO public.roles_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM public.roles r, public.permissions p
WHERE r.slug = 'auditor'
AND p.slug IN (
'memberships.read',
'users.read',
'usersroles.read'
);The slugs the seed defines but grants to nobody, memberships.create,
invites.update, the wildcards, are ready-made vocabulary for exactly this
moment. With seat-based billing Seat-based billing only the
first INSERT takes a fourth column, counts_toward_seat, which decides
whether holders consume a seat; see
A free role is one column away.
Mirror it in the Dart enum. The client's single source of truth is
enum Role; add the value and a priority arm:
enum Role {
owner('owner'),
admin('admin'),
member('member'),
auditor('auditor');
// ...
int get priority {
return switch (this) {
Role.owner => 3,
Role.admin => 2,
Role.member => 1,
Role.auditor => 0,
};
}
}Seed and enum ship together, or the members list crashes
Role.fromSlug throws on any slug the enum does not know, and the client
never fetches roles dynamically. The member DTOs parse every member's
role_slug when the members list loads, so a role seeded in SQL without its
enum entry crashes that screen for everyone who can see it.
Let the compiler point at the rest. Every switch on Role is
exhaustive, so after the previous step the analyzer lists the remaining
sites:
- The three policies:
OrganizationPolicyandMemberPolicyinlib/core/domain/authz/policies/organization_policy.dart, andSubscriptionPolicyinlib/core/domain/authz/policies/subscription_policy.dart. This is where you decide what the role may do in the app layer. - Five icon and label widgets under
lib/features/organization/presentation/views/. - Three role pickers and filters: the invite dialog, the assign dropdown on
the member profile sheet, and the members-list filter. Returning
nullfrom a dropdown's map arm keeps the role out of selection, exactly as theownerarm already does.
Add what the compiler cannot see. The role's display name is a
localization key, not an enum property. Add a key modeled on ownerRole,
adminRole and memberRole to lib/core/presentation/l10n/app_en.arb and
app_fr.arb, regenerate the localizations, and mirror the getter in
lib/core/presentation/utils/constants/texts.dart.
Extend the SQL role lists only if the role is owner-like. Skip this step
for ordinary roles: app.has_perm never asks which role granted a slug, so
the grants from the first step cover everything. The only SQL that names
roles directly is the set of app.has_any_role call sites with hardcoded
ARRAY['owner'] and ARRAY['owner', 'admin'] literals in
supabase/migrations/*_multi-tenancy-init.sql, plus the two display-role
pickers that ORDER BY CASE r.slug to choose which role to show for a
multi-role member. Extend those only if the new role belongs in those
privileged sets.
The database tests already have a fixture for this
The pgTAP suites in supabase/tests/database/multi_tenancy/ and
supabase/tests/database/seat_billing/ exercise roles end to end, and
tests.floot_seed_nonseat_role(slug, copy_from) in
supabase/tests/database/01_helpers/seat_billing/010_seat_fixtures.sql
clones an existing role's grants into a new one; copy its pattern when
covering yours.
Checking in the database
Nine SQL helpers answer authorization questions, and all nine are granted to
authenticated, so you can call any of them from your own policies, functions
and queries. Permissions are covered below; entitlements and step-up have
sections of their own further down.
| Helper | The question it answers |
|---|---|
| Permissions | |
app.has_perm(org, 'slug') Multi-tenancy only | Does the caller hold this permission here? |
app.is_member(org) Multi-tenancy only | Is the caller an active member here at all? |
app.has_any_role(org, ARRAY['owner']) Multi-tenancy only | Does the caller hold one of these roles here? |
app.perm_matches('reports.*', 'reports.advanced') | Does this granted pattern cover this requested slug? |
| Entitlements | |
app.has_entitlement('slug') | Does the caller's own subscription include this feature? |
app.has_entitlement(org, 'slug') Multi-tenancy only | Does the organization's plan include this feature? |
| Step-up | |
app.mfa_satisfied() | Is the session at the MFA level this caller requires? |
app.has_recent_totp_verification() | Has the caller passed a TOTP challenge recently? |
app.has_recent_identity_verification() | Has the caller proved their identity recently, by TOTP or a fresh sign-in? |
The generated project documents every helper, including the ones this page
skips, in supabase/docs/helpers_catalog.md.
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
has_perm, is_member and has_any_role consider only active
memberships, so deactivating a member revokes everything at once. A typical
policy on a table you add looks like this:
CREATE POLICY "Members with the permission can update projects"
ON public.projects
FOR UPDATE TO authenticated
USING ((SELECT app.has_perm(organization_id, 'projects.update')));There is one rule the migrations repeat in their own comments, and it is
worth internalizing: never negate has_perm or is_member. Both answer
"no" whenever they cannot prove "yes", which is the safe direction for a
positive gate and exactly the wrong one behind NOT. When a policy needs
"the target is not the owner", use has_any_role; it is the one helper
built to be safely negated, and the shipped policies use it that way.
Entitlements: what the plan allows
Permissions answer "may this role do it". Entitlements answer a different question: "does this plan include it". They are a second axis with the same shape: plans are to entitlements exactly what roles are to permissions, down to sharing the same wildcard matching.
The kit ships the machinery empty. Entitlement slugs are your product's
vocabulary (reports.advanced, exports.csv, api.access), so the tables
exist, the check function exists, and a commented worked example waits in
supabase/db_seeds/seed.sql; nothing is seeded because nothing generic would
be right.
The check is app.has_entitlement, which consults the caller's own
subscription; called with an organization
Multi-tenancy only it consults the organization's subscription
instead. A feature that should be gated by both axes composes them:
-- The member's role may read reports AND the org's plan includes them.
(SELECT app.has_perm(organization_id, 'reports.read'))
AND (SELECT app.has_entitlement(organization_id, 'reports.advanced'))Because a plan keeps its entitlement rows even after you retire it, grandfathering comes free: legacy subscribers keep exactly what their plan granted, and new plans grant whatever you decide next.
Step-up checks
Some actions are dangerous enough that being signed in, even with the right role, is not enough; the kit wants proof that it is really you, recently. That is a third axis, orthogonal to the other two, carried by three helpers:
app.has_recent_totp_verification(...)demands a fresh TOTP code and fails for users who never enrolled an authenticator. Deleting an organization requires it, and both sides of an ownership transfer prove themselves with it.app.has_recent_identity_verification(...)is the softer sibling: enrolled users must present a fresh TOTP code, everyone else a recent sign-in. Account deletion uses it, so not having MFA never blocks leaving.app.mfa_satisfied()is the session-level floor: users with a verified authenticator must be in an MFA-verified session; everyone else passes.
The app-side twin of these checks, including the freshness window and how the prompt appears, is covered in Step-up verification.
Checking on the client
The Dart side lives in lib/core/domain/authz/ and mirrors the database in
spirit: policies group the rules for one domain, actions are enums, and the
check is a plain synchronous call on the current user:
final canInvite = user.canInOrg(const OrganizationPolicy(), OrgAction.invite);That is the whole API surface most screens need: resolve the user's roles in the current organization, ask the policy, show or hide accordingly. Role resolution fails closed; no current organization, no matching membership, or a deactivated one all resolve to zero roles, and every check answers no.
Rules are composed from a small permission algebra, and the When predicate
is where the attribute-based part shows: it can consult anything about the
user and the data. The member-management policy uses it for "never on
yourself, never on the owner"; your own rules can go further and skip roles
entirely:
// Comments are editable by their author, once, within five minutes.
final canEdit = When<Comment>((u, c) {
final isAuthor = c.authorId == u.id;
final isRecent = DateTime.now().difference(c.createdAt).inMinutes < 5;
return isAuthor && !c.isEdited && isRecent;
});
user.check(canEdit, data: comment);The two layers are kept in sync by hand
There is no code generation between the seed file and the Dart policies.
When you grant a role a new permission, make the matching change in
lib/core/domain/authz/policies/, and the other way around; if the two
drift, the UI either offers what the database will refuse or hides what it
would allow.
The full authoring guide, including custom policies, global (non-organization)
policies and role resolvers, is in the generated project at
lib/core/domain/authz/README.md.