8 · Beyond the tables
The RLS Field Guide · 5 min read
The question this chapter answers: where else can data leak, even with perfect table policies?
TaskHarbor's table layer is now provably tight. It is also not the whole product: attachments live in storage, dashboards subscribe to changes, and convenience views and functions round out the API surface. Row policies govern none of these automatically. Each surface has its own authorization story, and each is a Chapter 4 pattern waiting to happen.
Storage: a separate policy universe
Buckets are not tables. Access control lives in RLS policies on storage.objects, written per bucket (Storage access control). With no policies, uploads to a private bucket fail; with "public" set on the bucket, every object is readable by anyone holding the URL — no policy involved at all.
The idiomatic tenant pattern encodes the workspace id as the first path segment. Uploads go to <workspace-id>/<filename>, and policies parse it:
create policy "task-files: members read" on storage.objects
for select to authenticated
using (
bucket_id = 'task-files'
and private.is_member(((storage.foldername(name))[1])::uuid)
);
create policy "task-files: members upload" on storage.objects
for insert to authenticated
with check (
bucket_id = 'task-files'
and private.is_member(((storage.foldername(name))[1])::uuid)
);
storage.foldername(name) splits the path; [1] takes the first segment. The same helper from Chapter 3 now guards files. Add update/delete policies only if your app overwrites or removes objects through user credentials, and audit existing buckets the way you audited tables:
select id, ("public") as is_public from storage.buckets order by id;
Every row needs an owner who can explain its visibility in one sentence.
Realtime: policies apply, except when they cannot
Realtime's Postgres Changes authorizes subscriptions using each table's own RLS policies — a client receives exactly the change stream its role could have selected (Realtime docs). Tables join the stream through the supabase_realtime publication:
alter publication supabase_realtime add table public.comments;
Two edges of that doc page deserve permanent sticky-note status:
- Delete events carry no row-level check. Postgres cannot verify against a row that no longer exists, so deletes flow to subscribers without a policy evaluation. If "who deleted this" must be invisible across tenants, do not stream deletes.
- Old row images require
replica identity full, which widens what update/delete payloads contain — check that the previous values now traveling through the socket are themselves safe for every subscriber.
Because authorization reuses table policies, realtime drift usually is table drift arriving by a second door. Fix the policy once and both doors close.
Edge Functions: the trusted tier
Functions run server-side, which makes them the right place for privileged operations — and the wrong place to relax. Two rules keep them honest (Edge Functions docs):
- Decide who may call the function before writing it. Verify the caller's JWT and resolve identity server-side; an unauthenticated endpoint backed by the service key is not "serverless," it is public infrastructure with your database attached.
- Prefer scoped queries over blanket privilege. A function that runs one tenant-scoped statement under the caller's identity inherits all the proof machinery of Chapters 2–6. A function that grabs the bypassing key for convenience opts out of it — justified sometimes (cross-tenant nightly jobs), and each such site deserves its one-sentence justification from Pattern 4.
Views: whose privileges do they run with?
By default a Postgres view executes with its creator's privileges — historically created as postgres on Supabase, which means it evaluates policies as a role that bypasses them (Supabase docs). A dashboard view over tasks can hand out every tenant's rows to whoever can select from the view.
On Postgres 15+, make views obey the caller's policies explicitly:
create view public.open_tasks
with (security_invoker = true)
as select id, project_id, title
from public.tasks
where not done;
Views created before this setting existed keep owner-rights silently, which is why Chapter 5's audit lists every view with its options. For pre-15 leftovers you cannot yet migrate, revoke access from client roles so the view is unreachable rather than unprotected.
Functions: definer is a scalpel
Database functions callable through the API are endpoints. Three declaration properties decide their safety, and TaskHarbor's helpers already model all three:
create or replace function private.is_member(ws uuid)
returns boolean language sql stable security definer
set search_path = ''
as $$
select exists (
select 1 from public.members m
where m.workspace_id = ws
and m.user_id = (select auth.uid())
)
$$;
revoke all on function private.is_member(uuid) from public;
grant execute on function private.is_member(uuid) to authenticated;
- Definer only when recursion or privilege elevation demands it, and then with the narrowest possible body. Every definer function in an exposed schema is invokable by qualifying clients with its creator's privileges — the docs' caution is blunt about keeping such helpers in unexposed schemas like TaskHarbor's
private. - Pin the search path (
set search_path = ''plus fully qualified names). Without it, a caller can shadow an unqualified name inside your function and execute it with elevated privileges. - Revoke, then grant deliberately. The default grant to
publicis how helper functions end up callable byanon.
Run Chapter 5's pg_proc query and demand those three sentences for every hit.
The map, completed
Table policies remain the center of gravity — storage and realtime defer to them, and well-written functions route through them. But the audit habit has to cover all four surfaces on every pass, because generators and assistants produce buckets, publications, views, and RPCs just as readily as tables, and they ship none of this chapter's discipline with them.
Check on your project
- List your buckets (
select id, ("public") from storage.buckets;) and write the one-sentence justification for each visibility setting. - For your most sensitive bucket, write the two policies above adapted to your path convention, then prove them: attempt a cross-tenant read and upload as another workspace's member.
- Inventory
supabase_realtimepublications and confirm every streamed table's policies are current — then decide, explicitly, whether delete events may stream. - Run the views query from Chapter 5. Any view missing
security_invoker = trueeither gets it today or loses client access until it does. - List every function in your exposed schemas with its three answers: definer or invoker, pinned search path or not, who holds execute.