Skip to content

feat(0024): flag SELECT using (true) when the policy name claims owner or role scope - #195

Open
ohad6k wants to merge 1 commit into
supabase:mainfrom
ohad6k:feat/0024-select-name-scope
Open

ohad6k wants to merge 1 commit into
supabase:mainfrom
ohad6k:feat/0024-select-name-scope

Conversation

@ohad6k

@ohad6k ohad6k commented Oct 4, 2026

Copy link
Copy Markdown

What

0024_rls_policy_always_true skips SELECT ... USING (true) because public read access is often intentional (per the review on #141). That holds for a policy like "Public profiles are viewable by everyone.". It doesn't hold for this one:

create policy "Users can view their own posts"
on public.posts
for select
to authenticated
using (true);

The name says per-user, but the policy returns every row to every signed in user. Today 0024 reports nothing for it. The name is the only thing in the catalog that says the author meant something narrower, and it's also what people read when they review policies in the dashboard.

This PR keeps the SELECT exemption but makes one narrow exception: a permissive SELECT policy for anon, authenticated or public, with an always-true USING, whose name claims scope. The name is lowercased and every non-alphanumeric run becomes a space, then it counts as claiming scope if it contains one of these as whole words:

  • own or their ("Users can view their own posts", "select_own_posts")
  • only owner, only owners, only the owner, only the owners
  • for owner, for owners, for the owner, for the owners
  • owner can, owners can, owner only, owners only
  • admin or admins followed by can, only, read, reads, view, views, see, sees, manage, manages, select, insert, update or delete ("Admin reads all activity", "admin_select_responses")
  • only admin, only admins
  • for admin or for admins at the very end of the name ("Allow authenticated read for admin", but not "... for admin reports")
  • service role

A bare owner or admin is not enough, because it often names the table's contents ("Allow reading admin users", "Public can view vehicle owners"). A name that contains public, everyone or anyone never counts.

It also appends one sentence to detail for any flagged policy with such a name where every expression the policy has is always true (a missing USING only counts for INSERT):

The policy name suggests access is limited to the row owner or a specific role, but the policy does not check who the caller is.

Policies where one clause really checks the owner (e.g. using (user_id = auth.uid()) with check (true)) are still flagged as before, but without that sentence, since it would be wrong for them.

The always-true checks on USING and WITH CHECK are now computed once in the policies CTE (qual_always_true, with_check_always_true) and reused. All existing test output is unchanged.

What does not change

  • Lint name, level (WARN), cache_key format, metadata keys and remediation URL are unchanged, so no Studio changes are needed.
  • UPDATE, DELETE, INSERT and ALL detection is unchanged. For those, the name only adds the sentence above.
  • SELECT using (true) policies whose name doesn't claim scope are still not flagged.
  • Restrictive policies, custom roles and tables without RLS are still skipped.

The lint's description text changed in one place. "SELECT policies with USING (true) are intentionally excluded as this pattern is often used deliberately for public read access." now reads "... are excluded as this pattern is often used deliberately for public read access, unless the policy name says it is limited to the row owner or a role."

Known limits

The matcher is English-name heuristics: camelCase names ("usersViewOwnPosts") and non-English names aren't matched, and a name that says public, everyone or anyone is skipped even if it also says own (so "Anyone can read own payments by phone" is not flagged, and neither is a name with public inside a table name, like "Users can view own public_profiles"). This would be the first splinter lint that reads policy names, which is a design choice for maintainers.

Where this comes from

I used Claude Code to read the migrations of 512 public AI-generated Supabase apps. Of the 1,521 SELECT using (true) policy names visible in the scan output (distinct repo and policy name pairs, 1,351 distinct names; the scanner truncates long lists), 36 in 26 repos have a name containing one of own, owner(s), their, self, admin(s) or service role. By that reading, 27 of those 36 claim per-user, admin or service role scope, for example "Users can view their own credentials", and the other 9 describe the table's contents or declare public or anyone access. The matcher flags 26 policies in 17 repos, all among the 27. The one it misses is the deliberate "Anyone can read own payments by phone". It flags nothing else among the 1,521.

Tests

New cases in test/sql/0024_rls_policy_always_true.sql:

  • flagged: "Users can view their own posts", "select_own_posts", "Admins can view all posts", "Service role can read posts", "Owners can view posts", "Only owners can read", "Readable by only the owner", "Read access for the owner", "Owner only read", "Visible to only admins", "All users can view their own posts", "Users can view own posts" with using (1=1), one name per admin verb ("Admin only", "Admin read posts", "Admin view", "Admin views posts", "Admins see posts", "Admin sees posts", "Admins manage posts", "Admin manages posts"), plus five real names: "Admin reads all activity", "Users can view their restaurant's marketing data", "Users can view their own import jobs", "Allow authenticated read for admin", "admin_select_responses"
  • not flagged: "Public profiles are viewable by everyone.", "Posts shown on the homepage", "Public read only", "Authenticated can read for admin reports", "Everyone can view their posts", "Users can view their own posts" with using (user_id = auth.uid()), plus five real names: "Allow reading admin users", "Allow public read access for admins", "Allow public read access for admin activity logs", "Public can view vehicle owners", "Anyone can read own payments by phone"
  • detail sentence present: UPDATE using (true) and INSERT with check (true) with ownership names, and "admin_insert_posts", "admin_update_posts", "admin_delete_posts"
  • detail sentence absent: "update_any_post" (neutral name), UPDATE where one of USING / WITH CHECK checks the owner, and DELETE with no USING

Each phrase in the matcher and each of public, everyone and anyone has a case that fails if it is removed. No existing expected output changed. The full suite passes locally on supabase/postgres:15.14.1.169 and 17.6.1.169 (29 of 29). bin/check_lints.py passes and splinter.sql is regenerated with bin/compile.py.

Docs

docs/0024_permissive_rls_policy.md gets a short section on policy names that claim scope, listing the phrases above and the names they miss, and a false positive note (rename the policy if the table really is public). I also removed two lines from the detected patterns that the lint has never matched: "Missing USING clause on permissive SELECT policies" and USING ('a'='a').

Noticed, not changed

  • When a policy has no to clause, roles is {-} (0::oid::regrole::text), so the detail ends "bypasses row-level security for -."
  • A missing USING on UPDATE, DELETE or ALL is treated as always true, but in Postgres a permissive policy with no USING grants no rows for those commands. For a FOR ALL policy with only with check (true), authenticated sees no rows through it, so the finding is right (inserts are open) but permissive_using and the "USING clause" wording are not. A DELETE policy with no USING, and an UPDATE policy with neither USING nor WITH CHECK, affect 0 rows (checked on 15 and 17, with a using (true) SELECT policy on the same table), so those findings are false positives. An UPDATE policy whose only clause is with check (true) also changes no rows by itself, but it is not harmless: Postgres ORs its WITH CHECK with the other permissive UPDATE policies on the table, which removes their write check, so flagging it is right. The new sentence is left off all policies with no USING, because "does not check who the caller is" would describe them inaccurately.

🤖 Generated with Claude Code

…r or role scope

Keeps the SELECT exemption from supabase#141, except for permissive SELECT policies
for anon, authenticated or public whose name claims per-user, admin or
service role scope. Adds a detail sentence when a scope-claiming name sits
on a policy where every expression is always true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ohad6k
ohad6k requested a review from a team as a code owner October 4, 2026 21:24

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant