shovelbasedocs

Permissions

There is no separate shovelbase permission system — access control is Postgres Row-Level Security (RLS), the same mechanism whether a request comes from the REST API, the SDK, or an edge function. Which policies apply depends entirely on which key or JWT the request carries:

CredentialPostgres roleRLS
anon keyanonApplies — safe to ship in browsers
a signed-in user's JWTauthenticatedApplies — auth.uid() resolves to that user
service_role keyservice_role / project ownerBypassed entirely — trusted servers only
anon keyno user signed inuser JWTsigned-in requestservice_role keytrusted serversanon roleauthenticated roleauth.uid() setowner rolebypasses RLSRLS policyusing (…), with check (…)per row✓ / ✗skips policy check
Why use it

If your app talks to the database directly from a browser or a phone, something has to stop one user from reading another's rows — and it cannot be the client, because the client is the thing you don't trust. Anyone can open devtools, take the anon key, and call /rest/v1 by hand. RLS answers that by moving the rule into Postgres itself: the filter is attached to the table, so every query gets it, no matter who wrote the query or forgot to.

The contrast is with the traditional setup, where a hand-written API layer holds the rules. There, security depends on every endpoint remembering its where user_id = …, and one forgotten clause in one handler is a data breach. With RLS the check is fail-closed and central — a new endpoint, a new client, or a query you didn't anticipate is still constrained, and the worst case for a missing policy is that legitimate access is denied rather than that everyone sees everything.

The catch worth knowing early: policies are predicates that run on every affected row, so a policy calling a slow function, or filtering on an unindexed column, is a performance problem that shows up on every query against that table. Index the columns your policies compare — usually the user-id foreign key.

Private per-user data
Notes, orders, messages — using (user_id = auth.uid()). The default shape, and the one most apps need.
Public read, owner write
Blog posts, product listings, a public profile: anyone (even signed-out) may select, only the author may insert or update.
Shared workspaces
Multi-tenant B2B, where access follows membership rather than ownership — the policy joins through a memberships table to check the row's team is one the caller belongs to.
Admin/service access
Back-office tools and background jobs that legitimately need to see everything use the service key from a trusted server, bypassing policies entirely.
SurfaceAvailability
Portal UIDatabase → Policies shows what exists (view-only); author policies in SQL Editor or a migration
CLINone directly — ship policies inside a migration
SDK / HTTPNot applicable — RLS is enforced by Postgres, not the client; the SDK just sends whichever key/JWT you configured it with

Enabling and writing a policy

RLS is off by default per table — turn it on, then add policies for the operations you want to allow. No matching policy means no access, for that operation, full stop:

alter table public.todos enable row level security;
create policy "own rows" on public.todos
for all to authenticated
using (user_id = auth.uid()) with check (user_id = auth.uid());

using gates which existing rows are visible/affected (select, update, delete); with check gates what a write is allowed to leave behind (insert, update). A common pattern is public reads plus owner-only writes:

create policy "anyone can read" on public.posts
for select to anon, authenticated using (true);
create policy "authors write their own" on public.posts
for insert to authenticated with check (author_id = auth.uid());

Access through membership

Team and workspace apps need “you can see this row if you belong to its team,” which a policy expresses as a subquery. Wrap it in a security definer function so the lookup itself isn't subject to the policies on memberships — otherwise the two tables' policies can recurse into each other:

-- Runs as the function's owner, so it can read memberships without
-- re-entering that table's own RLS policies.
create function public.is_member(team uuid) returns boolean
language sql security definer stable
set search_path = public
as $$
select exists (
select 1 from public.memberships
where team_id = team and user_id = auth.uid()
);
$$;
alter table public.documents enable row level security;
create policy "team members read" on public.documents
for select to authenticated using (is_member(team_id));
create policy "team members write" on public.documents
for insert to authenticated with check (is_member(team_id));
-- Policies are predicates evaluated per row: index what they compare.
create index on public.documents (team_id);
create index on public.memberships (user_id, team_id);

Common gotchas

  • Enabling RLS with zero policies blocks every non-owner request on that table — that's the fail-closed default, not a bug.
  • for all covers select/insert/update/delete with one policy; split it out if reads and writes need different conditions.
  • The service_role key bypasses RLS entirely — never send it to a browser or mobile app; use the anon key (or a user's JWT) there instead.
  • Foreign keys into auth.users work from a migration (it runs as the owner role), but RLS on auth.users itself is managed by GoTrue, not your policies.

See Database for where existing policies show up in the schema catalog, and Storage for the same model applied to storage.objects.