UPDATE policies have two halves; a blocked write succeeds silently

USING picks the rows a caller may touch, WITH CHECK vets the new values. Rows RLS filters out are simply not updated or deleted, with no error. Check the count.

RLS & Auth

· Chapter

24

·

3

min read

The answer. Each verb reads a policy differently. SELECT and DELETE use using to decide which existing rows are visible. INSERT uses with check to vet the row being written. UPDATE uses both: using against the current row (may I touch it) and with check against the proposed row (may it end up like this); leave with check out and Postgres reuses the using expression, which is fine, but write with check (true) and a tenant member can move a ticket into another tenant. The second thing is how failure looks: it does not. An UPDATE or DELETE whose rows are filtered out by RLS affects zero rows and returns no error, so client code that checks only error believes it succeeded. Ask for the rows back and assert on them; our RLS test suite treats "zero rows returned" as the negative case, never a thrown error.

The pattern.

create policy "member updates ticket" on public.tickets for update to authenticated
  using (rls_helpers.has_tenant_access(tickets.tenant))        -- which rows
  with check (rls_helpers.has_tenant_access(tickets.tenant));  -- which values (no tenant hopping)
create policy "author deletes ticket" on public.tickets for delete to authenticated
  using (rls_helpers.is_same_profile(tickets.created_by));
const { data, error } = await supabase.from("tickets").update({ status: "done" }).eq("id", id).select("id");
if (error) throw error;
if (!data?.length) throw new Error("not permitted or not found");     // the only signal you get
Verb using with check
select rows visible
insert new row
update rows touchable new row
delete rows deletable

Watch out.

  • RLS is row-level. A user allowed to update a row may rewrite every column of it, including created_by; column privileges or a reset trigger close that.
  • PostgREST refuses UPDATE and DELETE without a filter (pg-safeupdate), so .update(x) with no .eq() errors instead of touching the table. Keep that on.
  • INSERT and SELECT are separate: writing a row does not grant reading it back.

Related: column-privileges-hide-columns-rls-hides-rows · set-created-by-in-a-before-insert-trigger-never-from-the-client · rls-test-set-local-role-and-jwt-claims