🚀 Join the waitlist now! waitlist.floot.dev
LogoFlootdocs

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:

PermissionWhat it unlocksOwnerAdminMember
orgz.updateEdit the organization's name, slug, logo✓
memberships.readSee the member list✓✓✓
memberships.updateEdit, deactivate and reactivate members✓✓
memberships.deleteRemove members✓✓
usersroles.readSee who holds which role✓✓✓
usersroles.updateChange a member's role✓✓
invites.createInvite people, resend invitations✓✓
invites.readSee pending invitations✓✓
invites.revokeRevoke invitations✓✓
users.readView member profiles✓✓✓
billing.readSee the plan's public summary✓
billing.manageManage 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:

supabase/db_seeds/permissions_and_roles.sql
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:

supabase/db_seeds/permissions_and_roles.sql
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:

lib/core/domain/models/user.dart
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: OrganizationPolicy and MemberPolicy in lib/core/domain/authz/policies/organization_policy.dart, and SubscriptionPolicy in lib/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 null from a dropdown's map arm keeps the role out of selection, exactly as the owner arm 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.

HelperThe question it answers
Permissions
app.has_perm(org, 'slug') Multi-tenancy onlyDoes the caller hold this permission here?
app.is_member(org) Multi-tenancy onlyIs the caller an active member here at all?
app.has_any_role(org, ARRAY['owner']) Multi-tenancy onlyDoes 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 onlyDoes 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.

What's next?

On this page