From 1595c39bdd552c7a2895445ff10922db10cc516c Mon Sep 17 00:00:00 2001 From: RampantDespair Date: Wed, 5 Aug 2026 14:20:55 -0400 Subject: [PATCH] Add row-level security assertion Add has_rls() overloads for schema-qualified and search-path table lookups, with custom or generated descriptions. Cover enabled and disabled cases, document the assertion, and add compatibility patches that omit it on PostgreSQL 9.4 and earlier. --- .gitignore | 1 + Changes | 2 + Makefile | 7 ++ compat/9.4/pgtap--1.3.4--1.3.5.patch | 58 ++++++++++++++++ compat/install-9.4.patch | 55 +++++++++++++++ doc/pgtap.md | 32 +++++++++ ...--1.3.5.sql => pgtap--1.3.4--1.3.5.sql.in} | 51 ++++++++++++++ sql/pgtap.sql.in | 51 ++++++++++++++ test/expected/policy.out | 26 ++++++- test/sql/policy.sql | 68 ++++++++++++++++++- 10 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 compat/9.4/pgtap--1.3.4--1.3.5.patch rename sql/{pgtap--1.3.4--1.3.5.sql => pgtap--1.3.4--1.3.5.sql.in} (73%) diff --git a/.gitignore b/.gitignore index ac048a4de..351691158 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ bbin /sql/pgtap--0.97.0--0.98.0.sql /sql/pgtap--0.98.0--0.99.0.sql /sql/pgtap--0.99.0--1.0.0.sql +/sql/pgtap--1.3.4--1.3.5.sql /sql/pgtap-static.sql /sql/pgtap-static.sql.tmp* *.sql.orig diff --git a/Changes b/Changes index 2aa0095c3..246fecd60 100644 --- a/Changes +++ b/Changes @@ -12,6 +12,8 @@ Revision history for pgTAP and `hasnt_check()`. Also changed `has_unique()` argument types from `TEXT` to `NAME` so they can share a consistent overload order with the other constraint assertions. Thanks to @RampantDespair for the PR (#370). +* Added `has_rls()` to test whether row-level security is enabled on a table. + Thanks to @RampantDespair for the PR (#371). 1.3.4 2025-10-04T17:20:28Z -------------------------- diff --git a/Makefile b/Makefile index bf31c0bd3..acc628b30 100644 --- a/Makefile +++ b/Makefile @@ -218,6 +218,13 @@ endif mv sql/pgtap.tmp sql/pgtap.sql # Ugly hacks for now... TODO: script that understands $VERSION and will apply all the patch files for that version +EXTRA_CLEAN += sql/pgtap--1.3.4--1.3.5.sql +sql/pgtap--1.3.4--1.3.5.sql: sql/pgtap--1.3.4--1.3.5.sql.in + cp $< $@ +ifeq ($(shell echo $(VERSION) | grep -qE "^9[.][01234]" && echo yes || echo no),yes) + patch -p0 < compat/9.4/pgtap--1.3.4--1.3.5.patch +endif + EXTRA_CLEAN += sql/pgtap--0.99.0--1.0.0.sql sql/pgtap--0.99.0--1.0.0.sql: sql/pgtap--0.99.0--1.0.0.sql.in cp $< $@ diff --git a/compat/9.4/pgtap--1.3.4--1.3.5.patch b/compat/9.4/pgtap--1.3.4--1.3.5.patch new file mode 100644 index 000000000..9ab791958 --- /dev/null +++ b/compat/9.4/pgtap--1.3.4--1.3.5.patch @@ -0,0 +1,58 @@ +--- sql/pgtap--1.3.4--1.3.5.sql ++++ sql/pgtap--1.3.4--1.3.5.sql +@@ -99,55 +99,4 @@ + CREATE OR REPLACE FUNCTION hasnt_check ( NAME ) + RETURNS TEXT AS $$ + SELECT hasnt_check( $1, 'Table ' || quote_ident($1) || ' should not have a check constraint' ); + $$ LANGUAGE sql; +- +--- has_rls( schema, table, description ) +-CREATE OR REPLACE FUNCTION has_rls( NAME, NAME, TEXT ) +-RETURNS TEXT AS $$ +- SELECT ok( +- EXISTS( +- SELECT true +- FROM pg_catalog.pg_class c +- JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid +- WHERE n.nspname = $1 +- AND c.relname = $2 +- AND c.relrowsecurity = TRUE +- ), +- $3 +- ); +-$$ LANGUAGE SQL; +- +--- has_rls( schema, table ) +-CREATE OR REPLACE FUNCTION has_rls( NAME, NAME ) +-RETURNS TEXT AS $$ +- SELECT has_rls( +- $1, +- $2, +- 'Table ' || quote_ident($1) || '.' || quote_ident($2) +- || ' should have row-level security enabled' +- ); +-$$ LANGUAGE SQL; +- +--- has_rls( table, description ) +-CREATE OR REPLACE FUNCTION has_rls( NAME, TEXT ) +-RETURNS TEXT AS $$ +- SELECT ok( +- EXISTS( +- SELECT true +- FROM pg_catalog.pg_class c +- WHERE pg_catalog.pg_table_is_visible(c.oid) +- AND c.relname = $1 +- AND c.relrowsecurity = TRUE +- ), +- $2 +- ); +-$$ LANGUAGE SQL; +- +--- has_rls( table ) +-CREATE OR REPLACE FUNCTION has_rls( NAME ) +-RETURNS TEXT AS $$ +- SELECT has_rls( +- $1, +- 'Table ' || quote_ident($1) || ' should have row-level security enabled' +- ); +-$$ LANGUAGE SQL; diff --git a/compat/install-9.4.patch b/compat/install-9.4.patch index 6800934ff..6406b6292 100644 --- a/compat/install-9.4.patch +++ b/compat/install-9.4.patch @@ -252,3 +252,58 @@ /******************** INHERITANCE ***********************************************/ /* * Internal function to test whether the specified table in the specified schema +@@ -11596,54 +11369,3 @@ + 'Function ' || quote_ident($1) || '() should not be a procedure' + ); + $$ LANGUAGE sql; +- +--- has_rls( schema, table, description ) +-CREATE OR REPLACE FUNCTION has_rls( NAME, NAME, TEXT ) +-RETURNS TEXT AS $$ +- SELECT ok( +- EXISTS( +- SELECT true +- FROM pg_catalog.pg_class c +- JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid +- WHERE n.nspname = $1 +- AND c.relname = $2 +- AND c.relrowsecurity = TRUE +- ), +- $3 +- ); +-$$ LANGUAGE SQL; +- +--- has_rls( schema, table ) +-CREATE OR REPLACE FUNCTION has_rls( NAME, NAME ) +-RETURNS TEXT AS $$ +- SELECT has_rls( +- $1, +- $2, +- 'Table ' || quote_ident($1) || '.' || quote_ident($2) +- || ' should have row-level security enabled' +- ); +-$$ LANGUAGE SQL; +- +--- has_rls( table, description ) +-CREATE OR REPLACE FUNCTION has_rls( NAME, TEXT ) +-RETURNS TEXT AS $$ +- SELECT ok( +- EXISTS( +- SELECT true +- FROM pg_catalog.pg_class c +- WHERE pg_catalog.pg_table_is_visible(c.oid) +- AND c.relname = $1 +- AND c.relrowsecurity = TRUE +- ), +- $2 +- ); +-$$ LANGUAGE SQL; +- +--- has_rls( table ) +-CREATE OR REPLACE FUNCTION has_rls( NAME ) +-RETURNS TEXT AS $$ +- SELECT has_rls( +- $1, +- 'Table ' || quote_ident($1) || ' should have row-level security enabled' +- ); +-$$ LANGUAGE SQL; diff --git a/doc/pgtap.md b/doc/pgtap.md index ec750017e..ae5c5c0b8 100644 --- a/doc/pgtap.md +++ b/doc/pgtap.md @@ -8794,6 +8794,38 @@ missing policy command, like so: # have: INSERT # want: ALL +### `has_rls()` ### + +```sql +SELECT has_rls( :schema, :table, :description ); +SELECT has_rls( :schema, :table ); +SELECT has_rls( :table, :description ); +SELECT has_rls( :table ); +``` + +**Parameters** + +`:schema` +: Name of a schema in which to find the table. + +`:table` +: Name of a table. + +`:description` +: A short description of the test. + +This function tests whether or not row-level security is enabled for a table. +The first argument is a schema name, the second is a table name, and the third +is the test description. If you omit the schema, the table must be visible in +the search path. Example: + +```sql +SELECT has_rls('myschema'::name, 'sometable'::name); +``` + +If you omit the test description, it will be set to "Table `:table` should +have row-level security enabled". + No Test for the Wicked ====================== diff --git a/sql/pgtap--1.3.4--1.3.5.sql b/sql/pgtap--1.3.4--1.3.5.sql.in similarity index 73% rename from sql/pgtap--1.3.4--1.3.5.sql rename to sql/pgtap--1.3.4--1.3.5.sql.in index eda8d3b9b..169206aa9 100644 --- a/sql/pgtap--1.3.4--1.3.5.sql +++ b/sql/pgtap--1.3.4--1.3.5.sql.in @@ -100,3 +100,54 @@ CREATE OR REPLACE FUNCTION hasnt_check ( NAME ) RETURNS TEXT AS $$ SELECT hasnt_check( $1, 'Table ' || quote_ident($1) || ' should not have a check constraint' ); $$ LANGUAGE sql; + +-- has_rls( schema, table, description ) +CREATE OR REPLACE FUNCTION has_rls( NAME, NAME, TEXT ) +RETURNS TEXT AS $$ + SELECT ok( + EXISTS( + SELECT true + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid + WHERE n.nspname = $1 + AND c.relname = $2 + AND c.relrowsecurity = TRUE + ), + $3 + ); +$$ LANGUAGE SQL; + +-- has_rls( schema, table ) +CREATE OR REPLACE FUNCTION has_rls( NAME, NAME ) +RETURNS TEXT AS $$ + SELECT has_rls( + $1, + $2, + 'Table ' || quote_ident($1) || '.' || quote_ident($2) + || ' should have row-level security enabled' + ); +$$ LANGUAGE SQL; + +-- has_rls( table, description ) +CREATE OR REPLACE FUNCTION has_rls( NAME, TEXT ) +RETURNS TEXT AS $$ + SELECT ok( + EXISTS( + SELECT true + FROM pg_catalog.pg_class c + WHERE pg_catalog.pg_table_is_visible(c.oid) + AND c.relname = $1 + AND c.relrowsecurity = TRUE + ), + $2 + ); +$$ LANGUAGE SQL; + +-- has_rls( table ) +CREATE OR REPLACE FUNCTION has_rls( NAME ) +RETURNS TEXT AS $$ + SELECT has_rls( + $1, + 'Table ' || quote_ident($1) || ' should have row-level security enabled' + ); +$$ LANGUAGE SQL; diff --git a/sql/pgtap.sql.in b/sql/pgtap.sql.in index 68102272d..a2b9c1fbc 100644 --- a/sql/pgtap.sql.in +++ b/sql/pgtap.sql.in @@ -11596,3 +11596,54 @@ RETURNS TEXT AS $$ 'Function ' || quote_ident($1) || '() should not be a procedure' ); $$ LANGUAGE sql; + +-- has_rls( schema, table, description ) +CREATE OR REPLACE FUNCTION has_rls( NAME, NAME, TEXT ) +RETURNS TEXT AS $$ + SELECT ok( + EXISTS( + SELECT true + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid + WHERE n.nspname = $1 + AND c.relname = $2 + AND c.relrowsecurity = TRUE + ), + $3 + ); +$$ LANGUAGE SQL; + +-- has_rls( schema, table ) +CREATE OR REPLACE FUNCTION has_rls( NAME, NAME ) +RETURNS TEXT AS $$ + SELECT has_rls( + $1, + $2, + 'Table ' || quote_ident($1) || '.' || quote_ident($2) + || ' should have row-level security enabled' + ); +$$ LANGUAGE SQL; + +-- has_rls( table, description ) +CREATE OR REPLACE FUNCTION has_rls( NAME, TEXT ) +RETURNS TEXT AS $$ + SELECT ok( + EXISTS( + SELECT true + FROM pg_catalog.pg_class c + WHERE pg_catalog.pg_table_is_visible(c.oid) + AND c.relname = $1 + AND c.relrowsecurity = TRUE + ), + $2 + ); +$$ LANGUAGE SQL; + +-- has_rls( table ) +CREATE OR REPLACE FUNCTION has_rls( NAME ) +RETURNS TEXT AS $$ + SELECT has_rls( + $1, + 'Table ' || quote_ident($1) || ' should have row-level security enabled' + ); +$$ LANGUAGE SQL; diff --git a/test/expected/policy.out b/test/expected/policy.out index ff51445ca..69d35a2cf 100644 --- a/test/expected/policy.out +++ b/test/expected/policy.out @@ -1,5 +1,5 @@ \unset ECHO -1..180 +1..204 ok 1 - policies_are(schema, table, policies, desc) should pass ok 2 - policies_are(schema, table, policies, desc) should have the proper description ok 3 - policies_are(schema, table, policies, desc) should have the proper diagnostics @@ -180,3 +180,27 @@ ok 177 - policy_cmd_is(table, policy, command, desc) for ALL should fail should ok 178 - policy_cmd_is(table, policy, command) for ALL should fail should fail ok 179 - policy_cmd_is(table, policy, command) for ALL should fail should have the proper description ok 180 - policy_cmd_is(table, policy, command) for ALL should fail should have the proper diagnostics +ok 181 - has_rls(schema, table, desc) should pass +ok 182 - has_rls(schema, table, desc) should have the proper description +ok 183 - has_rls(schema, table, desc) should have the proper diagnostics +ok 184 - has_rls(schema, table) should pass +ok 185 - has_rls(schema, table) should have the proper description +ok 186 - has_rls(schema, table) should have the proper diagnostics +ok 187 - has_rls(table, desc) should pass +ok 188 - has_rls(table, desc) should have the proper description +ok 189 - has_rls(table, desc) should have the proper diagnostics +ok 190 - has_rls(table) should pass +ok 191 - has_rls(table) should have the proper description +ok 192 - has_rls(table) should have the proper diagnostics +ok 193 - has_rls(schema, table, desc) without RLS should fail +ok 194 - has_rls(schema, table, desc) without RLS should have the proper description +ok 195 - has_rls(schema, table, desc) without RLS should have the proper diagnostics +ok 196 - has_rls(schema, table) without RLS should fail +ok 197 - has_rls(schema, table) without RLS should have the proper description +ok 198 - has_rls(schema, table) without RLS should have the proper diagnostics +ok 199 - has_rls(table, desc) without RLS should fail +ok 200 - has_rls(table, desc) without RLS should have the proper description +ok 201 - has_rls(table, desc) without RLS should have the proper diagnostics +ok 202 - has_rls(table) without RLS should fail +ok 203 - has_rls(table) without RLS should have the proper description +ok 204 - has_rls(table) without RLS should have the proper diagnostics diff --git a/test/sql/policy.sql b/test/sql/policy.sql index 59f34c318..535968674 100644 --- a/test/sql/policy.sql +++ b/test/sql/policy.sql @@ -1,7 +1,7 @@ \unset ECHO \i test/setup.sql -SELECT plan(180); +SELECT plan(204); --SELECT * FROM no_plan(); -- This will be rolled back. :-) @@ -579,6 +579,72 @@ SELECT * FROM check_test( want: DELETE' ); +/****************************************************************************/ +-- Test has_rls(). +SELECT * FROM check_test( + has_rls( 'public', 'passwd', 'whatever' ), + true, + 'has_rls(schema, table, desc)', + 'whatever', + '' +); + +SELECT * FROM check_test( + has_rls( 'public', 'passwd'::NAME ), + true, + 'has_rls(schema, table)', + 'Table public.passwd should have row-level security enabled', + '' +); + +SELECT * FROM check_test( + has_rls( 'passwd', 'whatever' ), + true, + 'has_rls(table, desc)', + 'whatever', + '' +); + +SELECT * FROM check_test( + has_rls( 'passwd' ), + true, + 'has_rls(table)', + 'Table passwd should have row-level security enabled', + '' +); + +SELECT * FROM check_test( + has_rls( 'pg_catalog', 'pg_type', 'whatever' ), + false, + 'has_rls(schema, table, desc) without RLS', + 'whatever', + '' +); + +SELECT * FROM check_test( + has_rls( 'pg_catalog', 'pg_type'::NAME ), + false, + 'has_rls(schema, table) without RLS', + 'Table pg_catalog.pg_type should have row-level security enabled', + '' +); + +SELECT * FROM check_test( + has_rls( 'pg_type', 'whatever' ), + false, + 'has_rls(table, desc) without RLS', + 'whatever', + '' +); + +SELECT * FROM check_test( + has_rls( 'pg_type' ), + false, + 'has_rls(table) without RLS', + 'Table pg_type should have row-level security enabled', + '' +); + /****************************************************************************/ -- Finish the tests and clean up. SELECT * FROM finish();