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/0033*.sql -d contrib_regression

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

**Summary:** Detects column-level privileges for `anon` or `authenticated` that have no effect because the role also holds the same privilege on the whole table.

**Ramification:** The column grant looks like a restriction but is not one. The role can still read or write every column, including the ones the grant was meant to hide.

---

### Rationale

Postgres allows an operation if the role holds the privilege on the table or on the specific columns. A table-level `SELECT` covers every column, so a column-level `SELECT` on top of it adds nothing, and a column-level `REVOKE` cannot take away what the table-level grant gives.

This is easy to hit on Supabase. New tables in `public` can start with table-level grants to `anon` and `authenticated`, and a later `GRANT SELECT (a, b) ...` is meant to narrow that. It does not.

The lint reports a role that holds an entry for a privilege (`SELECT`, `INSERT`, `UPDATE` or `REFERENCES`) at column level and the same privilege at table level. Privileges inherited through role membership or granted to `PUBLIC` count as table-level.

### How to Resolve

Revoke the table-level privilege, then grant only the columns the role needs. Revoking at table level also clears the role's column-level entries of that kind, so grant the columns again afterwards:

```sql
revoke select on public.profiles from anon;
grant select (id, display_name) on public.profiles to anon;
```

If the privilege comes from `PUBLIC`, revoke it from `PUBLIC` instead:

```sql
revoke select on public.profiles from public;
```

If the role should see every column, drop the column-level grant, because it is redundant.

### Example

Given this problematic configuration:

```sql
grant select on public.profiles to anon;
grant select (id, display_name) on public.profiles to anon;
-- anon can still select email
```

Fix:

```sql
revoke select on public.profiles from anon;
grant select (id, display_name) on public.profiles to anon;
```

### False Positives

- Column grants left over from an earlier, stricter setup, while the table-level grant is intentional. Drop the column grant to silence the lint.
- Only `anon` and `authenticated` are examined.

### Limitation

A plain `REVOKE select (email) ON public.profiles FROM anon` against a table-level grant leaves no trace in the catalogue, because Postgres has nothing to remove. This lint cannot see that case. It only reports the column grants that were made alongside a table-level grant.
111 changes: 111 additions & 0 deletions lints/0033_column_grant_overridden_by_table_grant.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
create view lint."0033_column_grant_overridden_by_table_grant" as

-- Detects column-level privileges that have no effect because the same role
-- already holds the same privilege on the whole table.
--
-- Postgres checks table-level and column-level privileges separately and
-- allows the operation if either is held. A table-level SELECT therefore
-- covers every column, whatever column-level grants or revokes exist. A
-- project that grants `select (a, b)` to a role to expose only those columns,
-- while the role also holds table-level `select`, still exposes every column.
--
-- What the catalogue can and cannot show:
-- * Detectable: a role holds a column-level entry in pg_attribute.attacl
-- and the same privilege at table level. The column entry is redundant,
-- which is the footprint of a restriction that never took effect.
-- * Not detectable: a plain `REVOKE select (col) ... FROM role` against a
-- table-level grant. Postgres removes nothing and stores nothing, so no
-- trace remains in the catalogue.
-- The table-level check uses `has_table_privilege`, so privileges inherited
-- through role membership or granted to PUBLIC count. Only the Supabase API
-- roles `anon` and `authenticated` are examined.
with column_grants as (
select
a.attrelid,
acl.grantee,
acl.privilege_type,
array_agg(a.attname::text order by a.attnum) as column_names
from
pg_catalog.pg_attribute a
cross join lateral pg_catalog.aclexplode(a.attacl) acl
where
a.attnum > 0
and not a.attisdropped
and a.attacl is not null
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'REFERENCES')
group by
a.attrelid,
acl.grantee,
acl.privilege_type
),
findings as (
select
nsp.nspname as schema_name,
cls.relname as table_name,
r.rolname as role_name,
cg.privilege_type,
cg.column_names
from
column_grants cg
join pg_catalog.pg_class cls
on cls.oid = cg.attrelid
join pg_catalog.pg_namespace nsp
on nsp.oid = cls.relnamespace
join pg_catalog.pg_roles r
on r.oid = cg.grantee
where
cls.relkind in ('r', 'p', 'v', 'm', 'f')
and r.rolname in ('anon', 'authenticated')
and pg_catalog.has_table_privilege(r.rolname, cls.oid, cg.privilege_type)
and nsp.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and nsp.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'
)
-- extension-owned tables are not the user's to change
and not exists (
select 1
from pg_catalog.pg_depend d
where d.classid = 'pg_catalog.pg_class'::pg_catalog.regclass
and d.objid = cls.oid
and d.deptype = 'e'
)
)
select
'column_grant_overridden_by_table_grant' as name,
'Column Privilege Overridden By Table Privilege' as title,
'WARN' as level,
'EXTERNAL' as facing,
array['SECURITY'] as categories,
'Detects column-level privileges for `anon` or `authenticated` that have no effect because the role also holds the same privilege on the whole table. Revoke the table-level privilege to make the column grant restrictive.' as description,
format(
'Role `%s` has table-level `%s` on `%s.%s`, so its column-level `%s` grant on (%s) does not restrict access. The role can use every column.',
role_name,
privilege_type,
schema_name,
table_name,
privilege_type,
array_to_string(column_names, ', ')
) as detail,
'https://supabase.com/docs/guides/database/database-linter?lint=0033_column_grant_overridden_by_table_grant' as remediation,
jsonb_build_object(
'schema', schema_name,
'name', table_name,
'type', 'table',
'role', role_name,
'privilege', privilege_type,
'columns', column_names
) as metadata,
format(
'column_grant_overridden_by_table_grant_%s_%s_%s_%s',
schema_name,
table_name,
role_name,
privilege_type
) as cache_key
from
findings
order by
schema_name,
table_name,
role_name,
privilege_type;
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'
- Column Privilege Overridden By Table Privilege: '0033_column_grant_overridden_by_table_grant.md'

theme:
name: 'material'
Expand Down
113 changes: 112 additions & 1 deletion splinter.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1874,4 +1874,115 @@ where
)
order by
nsp.nspname,
cls.relname)
cls.relname)
union all
(
-- Detects column-level privileges that have no effect because the same role
-- already holds the same privilege on the whole table.
--
-- Postgres checks table-level and column-level privileges separately and
-- allows the operation if either is held. A table-level SELECT therefore
-- covers every column, whatever column-level grants or revokes exist. A
-- project that grants `select (a, b)` to a role to expose only those columns,
-- while the role also holds table-level `select`, still exposes every column.
--
-- What the catalogue can and cannot show:
-- * Detectable: a role holds a column-level entry in pg_attribute.attacl
-- and the same privilege at table level. The column entry is redundant,
-- which is the footprint of a restriction that never took effect.
-- * Not detectable: a plain `REVOKE select (col) ... FROM role` against a
-- table-level grant. Postgres removes nothing and stores nothing, so no
-- trace remains in the catalogue.
-- The table-level check uses `has_table_privilege`, so privileges inherited
-- through role membership or granted to PUBLIC count. Only the Supabase API
-- roles `anon` and `authenticated` are examined.
with column_grants as (
select
a.attrelid,
acl.grantee,
acl.privilege_type,
array_agg(a.attname::text order by a.attnum) as column_names
from
pg_catalog.pg_attribute a
cross join lateral pg_catalog.aclexplode(a.attacl) acl
where
a.attnum > 0
and not a.attisdropped
and a.attacl is not null
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'REFERENCES')
group by
a.attrelid,
acl.grantee,
acl.privilege_type
),
findings as (
select
nsp.nspname as schema_name,
cls.relname as table_name,
r.rolname as role_name,
cg.privilege_type,
cg.column_names
from
column_grants cg
join pg_catalog.pg_class cls
on cls.oid = cg.attrelid
join pg_catalog.pg_namespace nsp
on nsp.oid = cls.relnamespace
join pg_catalog.pg_roles r
on r.oid = cg.grantee
where
cls.relkind in ('r', 'p', 'v', 'm', 'f')
and r.rolname in ('anon', 'authenticated')
and pg_catalog.has_table_privilege(r.rolname, cls.oid, cg.privilege_type)
and nsp.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and nsp.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'
)
-- extension-owned tables are not the user's to change
and not exists (
select 1
from pg_catalog.pg_depend d
where d.classid = 'pg_catalog.pg_class'::pg_catalog.regclass
and d.objid = cls.oid
and d.deptype = 'e'
)
)
select
'column_grant_overridden_by_table_grant' as name,
'Column Privilege Overridden By Table Privilege' as title,
'WARN' as level,
'EXTERNAL' as facing,
array['SECURITY'] as categories,
'Detects column-level privileges for `anon` or `authenticated` that have no effect because the role also holds the same privilege on the whole table. Revoke the table-level privilege to make the column grant restrictive.' as description,
format(
'Role `%s` has table-level `%s` on `%s.%s`, so its column-level `%s` grant on (%s) does not restrict access. The role can use every column.',
role_name,
privilege_type,
schema_name,
table_name,
privilege_type,
array_to_string(column_names, ', ')
) as detail,
'https://supabase.com/docs/guides/database/database-linter?lint=0033_column_grant_overridden_by_table_grant' as remediation,
jsonb_build_object(
'schema', schema_name,
'name', table_name,
'type', 'table',
'role', role_name,
'privilege', privilege_type,
'columns', column_names
) as metadata,
format(
'column_grant_overridden_by_table_grant_%s_%s_%s_%s',
schema_name,
table_name,
role_name,
privilege_type
) as cache_key
from
findings
order by
schema_name,
table_name,
role_name,
privilege_type)
75 changes: 75 additions & 0 deletions test/expected/0033_column_grant_overridden_by_table_grant.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
begin;
set local search_path = '';
set local pgrst.db_schemas = 'public';
-- BASELINE: 0 issues on an empty schema
select * from lint."0033_column_grant_overridden_by_table_grant";
name | title | level | facing | categories | description | detail | remediation | metadata | cache_key
------+-------+-------+--------+------------+-------------+--------+-------------+----------+-----------
(0 rows)

-- The fixtures grant SELECT on new public tables to PUBLIC by default.
-- Revoke it so each case controls the table-level privileges itself.
create table public.profiles(
id int primary key,
display_name text,
email text
);
revoke all on table public.profiles from public, anon, authenticated;
savepoint a;
-- NEGATIVE EXAMPLE: column-level SELECT only, no table-level SELECT.
-- The grant really restricts the role to those columns. Must not fire.
grant select (id, display_name) on public.profiles to anon;
select * from lint."0033_column_grant_overridden_by_table_grant"; -- expect 0 rows
name | title | level | facing | categories | description | detail | remediation | metadata | cache_key
------+-------+-------+--------+------------+-------------+--------+-------------+----------+-----------
(0 rows)

rollback to savepoint a;
-- NEGATIVE EXAMPLE: table-level SELECT only, no column-level entries.
grant select on public.profiles to anon;
select * from lint."0033_column_grant_overridden_by_table_grant"; -- expect 0 rows
name | title | level | facing | categories | description | detail | remediation | metadata | cache_key
------+-------+-------+--------+------------+-------------+--------+-------------+----------+-----------
(0 rows)

rollback to savepoint a;
-- NEGATIVE EXAMPLE: table-level SELECT and column-level UPDATE are
-- different privileges, so neither overrides the other.
grant select on public.profiles to anon;
grant update (display_name) on public.profiles to anon;
select * from lint."0033_column_grant_overridden_by_table_grant"; -- expect 0 rows
name | title | level | facing | categories | description | detail | remediation | metadata | cache_key
------+-------+-------+--------+------------+-------------+--------+-------------+----------+-----------
(0 rows)

rollback to savepoint a;
-- POSITIVE EXAMPLE: the role holds table-level SELECT and a column-level
-- SELECT on two columns. The author meant to hide `email`, but the table
-- grant exposes it.
grant select on public.profiles to anon;
grant select (id, display_name) on public.profiles to anon;
select name, detail, cache_key from lint."0033_column_grant_overridden_by_table_grant"; -- expect 1 row
name | detail | cache_key
----------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------
column_grant_overridden_by_table_grant | Role `anon` has table-level `SELECT` on `public.profiles`, so its column-level `SELECT` grant on (id, display_name) does not restrict access. The role can use every column. | column_grant_overridden_by_table_grant_public_profiles_anon_SELECT
(1 row)

rollback to savepoint a;
-- POSITIVE EXAMPLE: the table-level privilege is inherited from PUBLIC
-- rather than granted to the role directly. It still overrides.
grant select on public.profiles to public;
grant select (id, display_name) on public.profiles to anon;
select metadata->>'role' as role, metadata->>'privilege' as privilege from lint."0033_column_grant_overridden_by_table_grant"; -- expect 1 row
role | privilege
------+-----------
anon | SELECT
(1 row)

-- RESOLUTION: drop the table-level privilege so the column grant restricts
revoke select on public.profiles from public;
select * from lint."0033_column_grant_overridden_by_table_grant"; -- expect 0 rows
name | title | level | facing | categories | description | detail | remediation | metadata | cache_key
------+-------+-------+--------+------------+-------------+--------+-------------+----------+-----------
(0 rows)

rollback;
4 changes: 3 additions & 1 deletion test/expected/queries_are_unionable.out
Original file line number Diff line number Diff line change
Expand Up @@ -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."0033_column_grant_overridden_by_table_grant";
name | title | level | facing | categories | description | detail | remediation | metadata | cache_key
------+-------+-------+--------+------------+-------------+--------+-------------+----------+-----------
(0 rows)
Expand Down
Loading