From 2d3503779d17ac5da79de9e7288fdedd2462316f Mon Sep 17 00:00:00 2001 From: Larik Date: Mon, 5 Oct 2026 16:54:13 +0300 Subject: [PATCH 1/2] feat: add policy_without_privilege lint (0031) Flags permissive RLS policies for anon/authenticated on tables in exposed schemas where the targeted role lacks the table privilege for the policy's command. Privileges are checked before RLS, so such a policy has no effect. Supabase stops granting Data API roles on new public tables by default (new projects since 2026-05-30, all projects from 2026-10-30), which makes this pattern easy to introduce in migrations. --- bin/installcheck | 2 +- docs/0031_policy_without_privilege.md | 58 +++++++ lints/0031_policy_without_privilege.sql | 138 +++++++++++++++ mkdocs.yaml | 1 + splinter.sql | 140 ++++++++++++++- test/sql/0031_policy_without_privilege.sql | 190 +++++++++++++++++++++ test/sql/queries_are_unionable.sql | 4 +- 7 files changed, 530 insertions(+), 3 deletions(-) create mode 100644 docs/0031_policy_without_privilege.md create mode 100644 lints/0031_policy_without_privilege.sql create mode 100644 test/sql/0031_policy_without_privilege.sql diff --git a/bin/installcheck b/bin/installcheck index 659aa2fb..035f9cfb 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -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} diff --git a/docs/0031_policy_without_privilege.md b/docs/0031_policy_without_privilege.md new file mode 100644 index 00000000..abd8455a --- /dev/null +++ b/docs/0031_policy_without_privilege.md @@ -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. diff --git a/lints/0031_policy_without_privilege.sql b/lints/0031_policy_without_privilege.sql new file mode 100644 index 00000000..9e23a790 --- /dev/null +++ b/lints/0031_policy_without_privilege.sql @@ -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; diff --git a/mkdocs.yaml b/mkdocs.yaml index 02864ac9..f653d16d 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -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' diff --git a/splinter.sql b/splinter.sql index 46bb331e..cecd9df8 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1874,4 +1874,142 @@ where ) order by nsp.nspname, - cls.relname) \ No newline at end of file + 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) \ No newline at end of file diff --git a/test/sql/0031_policy_without_privilege.sql b/test/sql/0031_policy_without_privilege.sql new file mode 100644 index 00000000..ea84bd7f --- /dev/null +++ b/test/sql/0031_policy_without_privilege.sql @@ -0,0 +1,190 @@ +begin; + set local search_path = ''; + set local pgrst.db_schemas = 'public'; + + -- 0 issues - no tables yet + select * from lint."0031_policy_without_privilege"; + + -- test/fixtures.sql grants SELECT (only) on new tables in public to PUBLIC, + -- so anon and authenticated can read but not write + create table public.posts( + id int primary key, + user_id uuid, + title text + ); + alter table public.posts enable row level security; + + savepoint a; + + ---------------------------------------- + -- Test: SELECT policy where the role has SELECT should NOT be flagged + ---------------------------------------- + create policy "select_own" + on public.posts + for select + to authenticated + using (user_id = auth.uid()); + + select * from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: INSERT policy where the role lacks INSERT SHOULD be flagged + ---------------------------------------- + create policy "insert_own" + on public.posts + for insert + to authenticated + with check (user_id = auth.uid()); + + select name, detail, cache_key from lint."0031_policy_without_privilege"; + + -- Resolution: grant the missing privilege + grant insert on public.posts to authenticated; + + select * from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: policy for anon and authenticated where only one role has the + -- privilege SHOULD be flagged, naming only the role without it + ---------------------------------------- + grant insert on public.posts to authenticated; + create policy "insert_any" + on public.posts + for insert + to anon, authenticated + with check (true); + + select detail, metadata->'roles' as roles from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: DELETE policy for both API roles, neither has DELETE, SHOULD be flagged + ---------------------------------------- + create policy "delete_own" + on public.posts + for delete + to anon, authenticated + using (user_id = auth.uid()); + + select detail, metadata->'roles' as roles from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: policy for public (no TO clause) where no API role has the + -- privilege SHOULD be flagged + ---------------------------------------- + create policy "update_own" + on public.posts + for update + using (user_id = auth.uid()); + + select detail, metadata->'roles' as roles from lint."0031_policy_without_privilege"; + + -- Resolution: granting to authenticated alone is enough for a policy for public + grant update on public.posts to authenticated; + + select * from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: column-level grant satisfies the privilege and should NOT be flagged + ---------------------------------------- + grant update (title) on public.posts to authenticated; + create policy "update_title" + on public.posts + for update + to authenticated + using (user_id = auth.uid()); + + select * from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: FOR ALL policy where the role holds at least one privilege should NOT be flagged + ---------------------------------------- + create policy "all_own" + on public.posts + for all + to authenticated + using (user_id = auth.uid()); + + select * from lint."0031_policy_without_privilege"; + + ---------------------------------------- + -- Test: FOR ALL policy where the role holds no privilege SHOULD be flagged + ---------------------------------------- + revoke select on public.posts from public; + + select name, detail, cache_key from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: restrictive policy should NOT be flagged + -- Without the privilege the role is denied anyway, which is what a restrictive policy wants + ---------------------------------------- + create policy "restrictive_insert" + on public.posts + as restrictive + for insert + to authenticated + with check (user_id = auth.uid()); + + select * from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: policy for a custom role (not anon/authenticated) should NOT be flagged + ---------------------------------------- + create role custom_role; + create policy "custom_insert" + on public.posts + for insert + to custom_role + with check (true); + + select * from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: table without RLS enabled should NOT be flagged (covered by 0007) + ---------------------------------------- + alter table public.posts disable row level security; + create policy "insert_own" + on public.posts + for insert + to authenticated + with check (user_id = auth.uid()); + + select * from lint."0031_policy_without_privilege"; + + rollback to savepoint a; + + ---------------------------------------- + -- Test: table in a schema not exposed via the API should NOT be flagged + ---------------------------------------- + create schema private; + create table private.notes( + id int primary key, + user_id uuid + ); + alter table private.notes enable row level security; + create policy "insert_own" + on private.notes + for insert + to authenticated + with check (user_id = auth.uid()); + + select * from lint."0031_policy_without_privilege"; + +rollback; diff --git a/test/sql/queries_are_unionable.sql b/test/sql/queries_are_unionable.sql index 3c5c39d3..e83d1945 100644 --- a/test/sql/queries_are_unionable.sql +++ b/test/sql/queries_are_unionable.sql @@ -58,6 +58,8 @@ begin; union all select * from lint."0029_authenticated_security_definer_function_executable" union all - select * from lint."0030_autovacuum_disabled"; + select * from lint."0030_autovacuum_disabled" + union all + select * from lint."0031_policy_without_privilege"; rollback; From f04cca68c5f9b101bc12aa874c65be479b8df1db Mon Sep 17 00:00:00 2001 From: Larik Date: Mon, 5 Oct 2026 16:57:46 +0300 Subject: [PATCH 2/2] test: add expected output for 0031_policy_without_privilege --- .../0031_policy_without_privilege.out | 213 ++++++++++++++++++ test/expected/queries_are_unionable.out | 4 +- 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 test/expected/0031_policy_without_privilege.out diff --git a/test/expected/0031_policy_without_privilege.out b/test/expected/0031_policy_without_privilege.out new file mode 100644 index 00000000..24c399f1 --- /dev/null +++ b/test/expected/0031_policy_without_privilege.out @@ -0,0 +1,213 @@ +begin; + set local search_path = ''; + set local pgrst.db_schemas = 'public'; + -- 0 issues - no tables yet + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- test/fixtures.sql grants SELECT (only) on new tables in public to PUBLIC, + -- so anon and authenticated can read but not write + create table public.posts( + id int primary key, + user_id uuid, + title text + ); + alter table public.posts enable row level security; + savepoint a; + ---------------------------------------- + -- Test: SELECT policy where the role has SELECT should NOT be flagged + ---------------------------------------- + create policy "select_own" + on public.posts + for select + to authenticated + using (user_id = auth.uid()); + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + ---------------------------------------- + -- Test: INSERT policy where the role lacks INSERT SHOULD be flagged + ---------------------------------------- + create policy "insert_own" + on public.posts + for insert + to authenticated + with check (user_id = auth.uid()); + select name, detail, cache_key from lint."0031_policy_without_privilege"; + name | detail | cache_key +--------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------- + policy_without_privilege | Table `public.posts` has RLS policy `insert_own` for `INSERT`, but authenticated has no INSERT privilege on the table, so the policy has no effect. | policy_without_privilege_public_posts_insert_own +(1 row) + + -- Resolution: grant the missing privilege + grant insert on public.posts to authenticated; + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + ---------------------------------------- + -- Test: policy for anon and authenticated where only one role has the + -- privilege SHOULD be flagged, naming only the role without it + ---------------------------------------- + grant insert on public.posts to authenticated; + create policy "insert_any" + on public.posts + for insert + to anon, authenticated + with check (true); + select detail, metadata->'roles' as roles from lint."0031_policy_without_privilege"; + detail | roles +--------------------------------------------------------------------------------------------------------------------------------------------+---------- + Table `public.posts` has RLS policy `insert_any` for `INSERT`, but anon has no INSERT privilege on the table, so the policy has no effect. | ["anon"] +(1 row) + + rollback to savepoint a; + ---------------------------------------- + -- Test: DELETE policy for both API roles, neither has DELETE, SHOULD be flagged + ---------------------------------------- + create policy "delete_own" + on public.posts + for delete + to anon, authenticated + using (user_id = auth.uid()); + select detail, metadata->'roles' as roles from lint."0031_policy_without_privilege"; + detail | roles +---------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------------------- + Table `public.posts` has RLS policy `delete_own` for `DELETE`, but anon and authenticated have no DELETE privilege on the table, so the policy has no effect. | ["anon", "authenticated"] +(1 row) + + rollback to savepoint a; + ---------------------------------------- + -- Test: policy for public (no TO clause) where no API role has the + -- privilege SHOULD be flagged + ---------------------------------------- + create policy "update_own" + on public.posts + for update + using (user_id = auth.uid()); + select detail, metadata->'roles' as roles from lint."0031_policy_without_privilege"; + detail | roles +---------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------------------- + Table `public.posts` has RLS policy `update_own` for `UPDATE`, but anon and authenticated have no UPDATE privilege on the table, so the policy has no effect. | ["anon", "authenticated"] +(1 row) + + -- Resolution: granting to authenticated alone is enough for a policy for public + grant update on public.posts to authenticated; + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + ---------------------------------------- + -- Test: column-level grant satisfies the privilege and should NOT be flagged + ---------------------------------------- + grant update (title) on public.posts to authenticated; + create policy "update_title" + on public.posts + for update + to authenticated + using (user_id = auth.uid()); + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + ---------------------------------------- + -- Test: FOR ALL policy where the role holds at least one privilege should NOT be flagged + ---------------------------------------- + create policy "all_own" + on public.posts + for all + to authenticated + using (user_id = auth.uid()); + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + ---------------------------------------- + -- Test: FOR ALL policy where the role holds no privilege SHOULD be flagged + ---------------------------------------- + revoke select on public.posts from public; + select name, detail, cache_key from lint."0031_policy_without_privilege"; + name | detail | cache_key +--------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------- + policy_without_privilege | Table `public.posts` has RLS policy `all_own` for `ALL`, but authenticated has no SELECT, INSERT, UPDATE, or DELETE privilege on the table, so the policy has no effect. | policy_without_privilege_public_posts_all_own +(1 row) + + rollback to savepoint a; + ---------------------------------------- + -- Test: restrictive policy should NOT be flagged + -- Without the privilege the role is denied anyway, which is what a restrictive policy wants + ---------------------------------------- + create policy "restrictive_insert" + on public.posts + as restrictive + for insert + to authenticated + with check (user_id = auth.uid()); + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + ---------------------------------------- + -- Test: policy for a custom role (not anon/authenticated) should NOT be flagged + ---------------------------------------- + create role custom_role; + create policy "custom_insert" + on public.posts + for insert + to custom_role + with check (true); + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + ---------------------------------------- + -- Test: table without RLS enabled should NOT be flagged (covered by 0007) + ---------------------------------------- + alter table public.posts disable row level security; + create policy "insert_own" + on public.posts + for insert + to authenticated + with check (user_id = auth.uid()); + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + ---------------------------------------- + -- Test: table in a schema not exposed via the API should NOT be flagged + ---------------------------------------- + create schema private; + create table private.notes( + id int primary key, + user_id uuid + ); + alter table private.notes enable row level security; + create policy "insert_own" + on private.notes + for insert + to authenticated + with check (user_id = auth.uid()); + select * from lint."0031_policy_without_privilege"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + +rollback; diff --git a/test/expected/queries_are_unionable.out b/test/expected/queries_are_unionable.out index 058f2dd0..2f032038 100644 --- a/test/expected/queries_are_unionable.out +++ b/test/expected/queries_are_unionable.out @@ -56,7 +56,9 @@ begin; union all select * from lint."0029_authenticated_security_definer_function_executable" union all - select * from lint."0030_autovacuum_disabled"; + select * from lint."0030_autovacuum_disabled" + union all + select * from lint."0031_policy_without_privilege"; name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows)