Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/installcheck
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ else
fi

# Execute the test fixtures
psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql -f lints/0030*.sql -d contrib_regression
psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql -f lints/0030*.sql -f lints/0031*.sql -d contrib_regression

# Run tests
${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS}
Expand Down
58 changes: 58 additions & 0 deletions docs/0031_policy_without_privilege.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
**Level:** INFO

**Summary:** An RLS policy targets `anon` or `authenticated`, but that role has no privilege on the table for the policy's command.

**Ramification:** The policy never takes effect. Requests through the Data API fail with `permission denied for table` instead of being filtered by the policy.

---

### Rationale

Postgres checks table privileges before it evaluates Row Level Security. A role needs both:

1. the table privilege for the command (`SELECT`, `INSERT`, `UPDATE`, or `DELETE`), granted with `GRANT`, and
2. a permissive RLS policy that allows the row.

If the privilege is missing, the query is rejected before any policy runs, so a policy such as:

```sql
create policy "insert_own"
on public.posts
for insert
to authenticated
with check (user_id = auth.uid());
```

has no effect unless `authenticated` also has `INSERT` on `public.posts`.

Previously, Supabase projects granted `SELECT`, `INSERT`, `UPDATE`, and `DELETE` on every new table in the `public` schema to `anon` and `authenticated` by default, so this rarely came up. Projects created since 2026-05-30, and all projects from 2026-10-30, no longer get these grants automatically on new tables ([changelog](https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically)). A migration that creates a table and its policies without matching `GRANT` statements produces exactly this pattern.

### What is checked

The lint only looks at tables that have RLS enabled, in schemas exposed through the Data API (`pgrst.db_schemas`, defaulting to `public`). For each permissive policy:

- **Policy targets `anon` and/or `authenticated`:** flagged if any targeted role lacks the privilege matching the policy's command.
- **Policy targets `public` (no `TO` clause):** flagged only if neither `anon` nor `authenticated` holds the privilege. It is common for such a policy to be meant for signed-in users only.
- **`FOR ALL` policies:** flagged only if the role holds none of `SELECT`, `INSERT`, `UPDATE`, or `DELETE`.

Privileges inherited from role membership or granted to `PUBLIC` count, and so do column-level grants (for example `grant update (title) on public.posts to authenticated`).

Restrictive policies are not checked. Without the privilege the role is already denied, which is what a restrictive policy is for.

### How to Resolve

If the policy is meant to be used, grant the matching privilege to the role:

```sql
grant insert on public.posts to authenticated;
```

If the policy is left over and the role should not have access, drop it so the table's access rules are easier to read:

```sql
drop policy "insert_own" on public.posts;
```

### False Positives

- **Intentionally disabled access:** privileges were revoked on purpose to block a role temporarily, and the policy was kept for later. Consider dropping the policy instead, or ignore the lint for that table.
138 changes: 138 additions & 0 deletions lints/0031_policy_without_privilege.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
create view lint."0031_policy_without_privilege" as

-- Detects permissive RLS policies targeting anon/authenticated on tables where that
-- role lacks the matching table privilege. Privileges are checked before RLS, so
-- such a policy never applies and API requests fail with "permission denied".
with api_roles as (
select
r.oid as role_oid,
r.rolname as role_name
from
pg_catalog.pg_roles r
where
r.rolname in ('anon', 'authenticated')
),
policies as (
select
n.nspname as schema_name,
c.relname as table_name,
c.oid as table_oid,
p.polname as policy_name,
p.polroles as role_oids,
case p.polcmd
when 'r' then 'SELECT'
when 'a' then 'INSERT'
when 'w' then 'UPDATE'
when 'd' then 'DELETE'
when '*' then 'ALL'
end as command
from
pg_catalog.pg_policy p
join pg_catalog.pg_class c
on p.polrelid = c.oid
join pg_catalog.pg_namespace n
on c.relnamespace = n.oid
left join pg_catalog.pg_depend dep
on c.oid = dep.objid
and dep.deptype = 'e'
and dep.classid = 'pg_catalog.pg_class'::regclass
where
c.relkind = 'r' -- regular tables
and c.relrowsecurity
and p.polpermissive
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
and dep.objid is null -- exclude tables owned by extensions
),
policy_roles as (
-- Each API role the policy targets, along with whether that role holds the privilege
select
p.schema_name,
p.table_name,
p.policy_name,
p.command,
p.role_oids = array[0::oid] as targets_public,
ar.role_name,
case p.command
-- has_any_column_privilege also accepts column-level grants
when 'SELECT' then pg_catalog.has_any_column_privilege(ar.role_oid, p.table_oid, 'SELECT')
when 'INSERT' then pg_catalog.has_any_column_privilege(ar.role_oid, p.table_oid, 'INSERT')
when 'UPDATE' then pg_catalog.has_any_column_privilege(ar.role_oid, p.table_oid, 'UPDATE')
when 'DELETE' then pg_catalog.has_table_privilege(ar.role_oid, p.table_oid, 'DELETE')
-- A FOR ALL policy is effective as long as the role holds any of the four privileges
when 'ALL' then pg_catalog.has_any_column_privilege(ar.role_oid, p.table_oid, 'SELECT, INSERT, UPDATE')
or pg_catalog.has_table_privilege(ar.role_oid, p.table_oid, 'DELETE')
end as has_privilege
from
policies p
join api_roles ar
on p.role_oids = array[0::oid] -- public (all roles)
or ar.role_oid = any(p.role_oids)
),
ineffective_policies as (
select
schema_name,
table_name,
policy_name,
command,
array_agg(role_name order by role_name) filter (where not has_privilege) as roles
from
policy_roles
group by
schema_name,
table_name,
policy_name,
command,
targets_public
having
-- Explicitly targeted roles: flag if any of them lacks the privilege.
-- Policies for public: flag only if no API role holds the privilege, since a
-- policy for public is commonly meant for authenticated users only.
case
when targets_public then not bool_or(has_privilege)
else not bool_and(has_privilege)
end
)
select
'policy_without_privilege' as name,
'Policy Without Privilege' as title,
'INFO' as level,
'EXTERNAL' as facing,
array['SECURITY'] as categories,
'Detects RLS policies for the anon or authenticated roles on tables where that role lacks the matching table privilege. Privileges are checked before RLS, so the policy has no effect until the privilege is granted.' as description,
format(
'Table `%s.%s` has RLS policy `%s` for `%s`, but %s %s no %s privilege on the table, so the policy has no effect.',
schema_name,
table_name,
policy_name,
command,
array_to_string(roles, ' and '),
case when cardinality(roles) > 1 then 'have' else 'has' end,
case command
when 'ALL' then 'SELECT, INSERT, UPDATE, or DELETE'
else command
end
) as detail,
'https://supabase.com/docs/guides/database/database-linter?lint=0031_policy_without_privilege' as remediation,
jsonb_build_object(
'schema', schema_name,
'name', table_name,
'type', 'table',
'policy_name', policy_name,
'command', command,
'roles', roles
) as metadata,
format(
'policy_without_privilege_%s_%s_%s',
schema_name,
table_name,
policy_name
) as cache_key
from
ineffective_policies
order by
schema_name,
table_name,
policy_name;
1 change: 1 addition & 0 deletions mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ nav:
- Public Can Execute SECURITY DEFINER Function: '0028_anon_security_definer_function_executable.md'
- Signed-In Users Can Execute SECURITY DEFINER Function: '0029_authenticated_security_definer_function_executable.md'
- Autovacuum Disabled: '0030_autovacuum_disabled.md'
- Policy Without Privilege: '0031_policy_without_privilege.md'

theme:
name: 'material'
Expand Down
140 changes: 139 additions & 1 deletion splinter.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1874,4 +1874,142 @@ where
)
order by
nsp.nspname,
cls.relname)
cls.relname)
union all
(
-- Detects permissive RLS policies targeting anon/authenticated on tables where that
-- role lacks the matching table privilege. Privileges are checked before RLS, so
-- such a policy never applies and API requests fail with "permission denied".
with api_roles as (
select
r.oid as role_oid,
r.rolname as role_name
from
pg_catalog.pg_roles r
where
r.rolname in ('anon', 'authenticated')
),
policies as (
select
n.nspname as schema_name,
c.relname as table_name,
c.oid as table_oid,
p.polname as policy_name,
p.polroles as role_oids,
case p.polcmd
when 'r' then 'SELECT'
when 'a' then 'INSERT'
when 'w' then 'UPDATE'
when 'd' then 'DELETE'
when '*' then 'ALL'
end as command
from
pg_catalog.pg_policy p
join pg_catalog.pg_class c
on p.polrelid = c.oid
join pg_catalog.pg_namespace n
on c.relnamespace = n.oid
left join pg_catalog.pg_depend dep
on c.oid = dep.objid
and dep.deptype = 'e'
and dep.classid = 'pg_catalog.pg_class'::regclass
where
c.relkind = 'r' -- regular tables
and c.relrowsecurity
and p.polpermissive
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
and dep.objid is null -- exclude tables owned by extensions
),
policy_roles as (
-- Each API role the policy targets, along with whether that role holds the privilege
select
p.schema_name,
p.table_name,
p.policy_name,
p.command,
p.role_oids = array[0::oid] as targets_public,
ar.role_name,
case p.command
-- has_any_column_privilege also accepts column-level grants
when 'SELECT' then pg_catalog.has_any_column_privilege(ar.role_oid, p.table_oid, 'SELECT')
when 'INSERT' then pg_catalog.has_any_column_privilege(ar.role_oid, p.table_oid, 'INSERT')
when 'UPDATE' then pg_catalog.has_any_column_privilege(ar.role_oid, p.table_oid, 'UPDATE')
when 'DELETE' then pg_catalog.has_table_privilege(ar.role_oid, p.table_oid, 'DELETE')
-- A FOR ALL policy is effective as long as the role holds any of the four privileges
when 'ALL' then pg_catalog.has_any_column_privilege(ar.role_oid, p.table_oid, 'SELECT, INSERT, UPDATE')
or pg_catalog.has_table_privilege(ar.role_oid, p.table_oid, 'DELETE')
end as has_privilege
from
policies p
join api_roles ar
on p.role_oids = array[0::oid] -- public (all roles)
or ar.role_oid = any(p.role_oids)
),
ineffective_policies as (
select
schema_name,
table_name,
policy_name,
command,
array_agg(role_name order by role_name) filter (where not has_privilege) as roles
from
policy_roles
group by
schema_name,
table_name,
policy_name,
command,
targets_public
having
-- Explicitly targeted roles: flag if any of them lacks the privilege.
-- Policies for public: flag only if no API role holds the privilege, since a
-- policy for public is commonly meant for authenticated users only.
case
when targets_public then not bool_or(has_privilege)
else not bool_and(has_privilege)
end
)
select
'policy_without_privilege' as name,
'Policy Without Privilege' as title,
'INFO' as level,
'EXTERNAL' as facing,
array['SECURITY'] as categories,
'Detects RLS policies for the anon or authenticated roles on tables where that role lacks the matching table privilege. Privileges are checked before RLS, so the policy has no effect until the privilege is granted.' as description,
format(
'Table `%s.%s` has RLS policy `%s` for `%s`, but %s %s no %s privilege on the table, so the policy has no effect.',
schema_name,
table_name,
policy_name,
command,
array_to_string(roles, ' and '),
case when cardinality(roles) > 1 then 'have' else 'has' end,
case command
when 'ALL' then 'SELECT, INSERT, UPDATE, or DELETE'
else command
end
) as detail,
'https://supabase.com/docs/guides/database/database-linter?lint=0031_policy_without_privilege' as remediation,
jsonb_build_object(
'schema', schema_name,
'name', table_name,
'type', 'table',
'policy_name', policy_name,
'command', command,
'roles', roles
) as metadata,
format(
'policy_without_privilege_%s_%s_%s',
schema_name,
table_name,
policy_name
) as cache_key
from
ineffective_policies
order by
schema_name,
table_name,
policy_name)
Loading