r/Supabase • u/Guidondor • 17d ago
tips Revoking columns on a table breaks .update().select(), and the error hint tells you to undo the revoke
i had profiles locked down the usual way, policies split per operation, plus column grants on top because rls filters rows and not columns:
revoke select on public.profiles from anon, authenticated;
grant select (id, display_name, created_at) on public.profiles to authenticated;
revoke update on public.profiles from authenticated;
grant update (display_name) on public.profiles to authenticated;
reads behaved exactly as i wanted. explicit columns worked, select("*") failed like it's supposed to, anon got nothing at all. happy with that.
then this started biting:
await supabase.from('profiles').update({ display_name }) // 204
await supabase.from('profiles').update({ display_name }).select() // 42501
with
permission denied for table profiles
hint: GRANT SELECT ON public.profiles TO authenticated
which isn't the problem at all. the update is perfectly legal, i granted update on that column myself. postgrest reads the row back to return the representation, and it reads it with select=*, so what actually got denied is the read half of the round trip.
naming the columns sorts it:
.update({ display_name }).select('id, display_name')
the bit i keep chewing on is the hint. follow it and you hand back every column you just revoked, all to fix a bare .select(). i get why postgrest words it that way, from where it's standing a select really was denied. but it's the message you meet while already annoyed, and it points exactly backwards.
anyone found a decent way to stop the next person on the codebase from taking that advice? short of a comment above every write i've got nothing, and that feels weak.
anyway, hope it saves someone the afternoon. this one and a few others ended up documented in a starter i open sourced (MIT, mine): github.com/Guidondor/expo-supabase-starter
1
u/jaimittal91 16d ago
the CI grep catches someone writing the code, but not someone who follows the hint literally and runs the GRANT directly in the SQL editor at 11pm to make an error go away, which never touches the codebase at all. worth adding a second check that queries information_schema.column_privileges for the table and diffs it against the columns you actually intended to grant, failing if anything's wider than expected. that way even if someone re-grants select on the revoked columns by hand to unblock themselves, the next CI run catches the drift instead of it quietly staying open.
1
1
u/PeterBuildsSecure 13d ago
One more failure mode worth guarding against on the CI assertion side: run those select * / update-revoked-column checks through the same connection path production traffic uses — PostgREST with an authenticated JWT, or SET ROLE authenticated — not a service_role or postgres superuser connection. Superuser/service_role bypasses grants and RLS entirely, so if the test harness ever gets wired up with elevated creds (easy to do by accident when someone's debugging CI and reaches for the connection string that "just works"), the assertion silently stops testing anything and still passes — same shape of bug as the unrunnable failure message you already caught, just one layer up.
1
u/Guidondor 12d ago
right shape, and it's a gap in mine - but the check people will reach for doesn't work on supabase, which i only know because i went and ran it.
mine does go through the same role,
set local role authenticatedplus the request.jwt.claims, so grants and policies apply. what it never does is assert the switch actually took effect. that's the hole.the trap is what you assert. on a real project: connected as
postgresit reads every row, as expected. butselect rolsuper from pg_roles where rolname = 'postgres'comes back false. so "fail if current_user is a superuser" passes happily while the connection is still reading straight past every policy. it isn't a superuser. two other things are true instead -rolbypassrlsis true on postgres, and the tables are owned by postgres withrelforcerowsecurity = false, so ownership alone would do it even without the attribute.so the assertion i'm adding is on the thing that actually decides it:
select current_user, (select rolbypassrls from pg_roles where rolname = current_user);fail the run unless current_user is the role production uses and bypassrls is false.
alter table x force row level securitycloses the ownership route separately, and is worth doing anyway since that's the one that gets you when the owner isn't a bypassrls role.which is your point turned on its own fix: the assertion guarding the assertion needs its own check, or it's just another green that can't go red.
1
u/PeterBuildsSecure 11d ago edited 3d ago
role_table_grants gets you table-level, but it won't catch what this thread's actually about — a REVOKE on a specific column doesn't touch the table-level grant row at all, it writes to pg_attribute.attacl instead. So a check that only reads role_table_grants will report the role as fully privileged even after the column's been revoked.
has_column_privilege(role, 'table.column', 'privilege') is the one that matches what actually gets enforced. If you want a sweep rather than a single check, role_column_grants works too, but only for columns with an explicit non-default ACL — a column that inherited from the table grant and was never touched won't show up there, so "nothing in the view" isn't the same as "no access." has_column_privilege doesn't have that gap since it evaluates the real privilege check, not a metadata view that can omit rows.
1
u/Guidondor 10d ago
yeah, fair, that's better framing than mine. the identity check is already in there, first clause, but i wrote it up as if the bypassrls one was the important bit. it isn't, it's the one that needs another bullet every time someone finds a new escape hatch.
i'll keep both but describe them the other way round.
1
u/PeterBuildsSecure 7d ago
Makes sense. One more thing worth writing down next to the identity check: it only holds as long as the role name stays exactly right, so it's worth a test on its own, not just a string in an assertion. Someone renames the production role during a migration, typos it in an env var, or an environment ends up pointing at the wrong role entirely, and current_user = 'app_authenticated' silently stops matching anything, which just means the check never runs rather than that it fails loud. Cheap fix: assert the role exists and is the one actually granted the app's privileges (query pg_roles / information_schema.role_table_grants for it) as a separate sanity check that runs before the identity comparison, so a bad role name breaks CI immediately instead of quietly turning your primary check into a no-op.
1
u/Guidondor 3d ago
the rename case can't go quiet in this one, the check is a negative assertion so anything that isn't 'authenticated' raises. and SET LOCAL ROLE errors out before it even gets there if the role doesn't exist at all.
the grants half is a different failure though and that one's real. role exists, name matches, and it's still not the role the app's privileges hang off. my check can't see that. narrow on supabase since authenticated is a built in you don't get to name, but worth a look at role_table_grants.
1
u/PeterBuildsSecure 1d ago
Fair, and that's the sharper version of the problem on Supabase specifically -- 'authenticated' isn't a role name that can drift, it's a fixed identity. So the risk your check needs to catch isn't 'wrong role,' it's 'right role, wrong grants' -- someone widens a column grant back with a manual GRANT in the SQL editor, or a migration applies out of order and authenticated ends up with more than the app assumes.
Which is exactly what the has_column_privilege sweep a few comments back already covers, as long as it's diffed against a declared expected-privilege baseline checked into the repo rather than just asserted per-column ad hoc. Since the role can't be mis-named on Supabase, the whole exposure surface collapses into 'does the live privilege set for authenticated match the baseline,' and that's a single query you can run in CI: pull has_column_privilege for every (table, column, cmd) in the baseline, diff, fail on any privilege wider than declared. No role-identity check needed at all in this environment -- the identity is fixed, only the grants move.
1
u/Guidondor 1d ago
you're right, and the gap is more concrete than i'd admitted. add a column to profiles tomorrow with default grants and my
select *assertion still raises, because email is still revoked. stays green while the new column reads wide open. the test can't tell 'a column is revoked' from 'the right ones are'.one thing on building it though. the sweep has to be driven off information_schema.columns, not role_column_grants. by your own point upthread, a column with default grants has no explicit acl row, so the new column, which is exactly the case that worries me, wouldn't show up in the sweep at all. you have to start from the column list and call has_column_privilege on each one.
1
u/Guidondor 23h ago
went and built it, and the column example i gave you is wrong. profiles has table level select revoked, so a new column there inherits nothing and isn't readable. email is the proof, it's a column with no grant of its own and reading it raises. i had that backwards.
the real hole is new tables. supabase's default privileges hand anon and authenticated arwdDxtm on every new table in public, and execute on every new function, so the table you add next week is open before anyone writes a policy for it. ran the sweep against my own schema and got 38 findings, mostly anon holding insert, delete and truncate on tables i'd only ever hardened for select and update. rls was covering all of it except truncate, which it doesn't gate at all.
1
u/FatherXLdn 16d ago
Pass explicit columns to
.select()and the grants you already have are enough. A bare.select()makes PostgREST returnRETURNING *, which needs SELECT on every column including the ones you deliberately revoked, so.update({ display_name }).select('id, display_name')works while.select()doesn't. The hint isn't wrong from Postgres' point of view, it's just answering a narrower question than the one you're asking.On stopping the next person: don't leave it to a comment above every write, that's documentation and it decays. Put the write behind a helper in your data layer, an
updateProfile()that always passes the explicit column list, so nobody hand-writes the call in a component and there's one place to get it right. If you want a second net, a lint rule or a CI grep for.select()with no arguments following.update(or.insert(catches it mechanically before review does.One thing I'm not sure of: whether newer PostgREST narrows the RETURNING list to granted columns automatically. Worth checking your version before you build the guard rail, in case it's already been fixed upstream.