diff --git a/build/transform.sh b/build/transform.sh index c8825580..3914c82d 100755 --- a/build/transform.sh +++ b/build/transform.sh @@ -607,6 +607,31 @@ END { echo "PASS: get_batch_cursor SECURITY header injected (extra_where is trusted SQL)" +# Promote get_queue_info() / get_queue_info(text) to SECURITY DEFINER. +# Upstream PgQ ships these as SECURITY INVOKER, but get_queue_info(text) +# internally calls pgque.seq_getval(text), whose ACL is admin-only. Both +# overloads are granted to pgque_reader and documented as reader-usable +# monitoring functions, so a reader session would hit +# ERROR: permission denied for function seq_getval +# at runtime. The sibling get_consumer_info / get_batch_info are already +# SECURITY DEFINER for exactly this reason; mirror that pattern here. +# SECURITY DEFINER MUST pin search_path (CLAUDE.md), so attach +# "set search_path = pgque, pg_catalog" in the same step. This grants no +# privilege beyond reading queue metadata + the queue event sequence. +GET_QUEUE_INFO_FILE="${OUTPUT_DIR}/functions/pgque.get_queue_info.sql" +sedi -E \ + 's/^(\$\$ language plpgsql);$/\1 security definer set search_path = pgque, pg_catalog;/' \ + "${GET_QUEUE_INFO_FILE}" + +defcount=$(grep -c 'language plpgsql security definer set search_path = pgque, pg_catalog;' \ + "${GET_QUEUE_INFO_FILE}") +if [[ "${defcount}" -ne 2 ]]; then + echo "ERROR: expected 2 SECURITY DEFINER get_queue_info overloads, got ${defcount}" >&2 + exit 1 +fi + +echo "PASS: get_queue_info promoted to SECURITY DEFINER (reader-safe seq_getval)" + # -- Assembly: build sql/pgque.sql ------------------------------------ echo "" diff --git a/sql/pgque-tle.sql b/sql/pgque-tle.sql index 8d4de44f..46c7ac90 100644 --- a/sql/pgque-tle.sql +++ b/sql/pgque-tle.sql @@ -2656,7 +2656,7 @@ begin end loop; return; end; -$$ language plpgsql; +$$ language plpgsql security definer set search_path = pgque, pg_catalog; create or replace function pgque.get_queue_info( in i_queue_name text, @@ -2737,7 +2737,7 @@ begin end loop; return; end; -$$ language plpgsql; +$$ language plpgsql security definer set search_path = pgque, pg_catalog; create or replace function pgque.get_consumer_info( diff --git a/sql/pgque.sql b/sql/pgque.sql index bf70020b..080c719d 100644 --- a/sql/pgque.sql +++ b/sql/pgque.sql @@ -2568,7 +2568,7 @@ begin end loop; return; end; -$$ language plpgsql; +$$ language plpgsql security definer set search_path = pgque, pg_catalog; create or replace function pgque.get_queue_info( in i_queue_name text, @@ -2649,7 +2649,7 @@ begin end loop; return; end; -$$ language plpgsql; +$$ language plpgsql security definer set search_path = pgque, pg_catalog; create or replace function pgque.get_consumer_info( diff --git a/tests/run_all.sql b/tests/run_all.sql index 7ed6a1bf..565164a9 100644 --- a/tests/run_all.sql +++ b/tests/run_all.sql @@ -118,6 +118,9 @@ \echo 'Running: test_e2e_role_split' \i tests/test_e2e_role_split.sql +\echo 'Running: test_get_queue_info_reader' +\i tests/test_get_queue_info_reader.sql + \echo 'Running: test_cooperative_consumers' \i tests/test_cooperative_consumers.sql diff --git a/tests/test_get_queue_info_reader.sql b/tests/test_get_queue_info_reader.sql new file mode 100644 index 00000000..9181e973 --- /dev/null +++ b/tests/test_get_queue_info_reader.sql @@ -0,0 +1,57 @@ +-- test_get_queue_info_reader.sql -- pgque_reader can call get_queue_info +-- Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license. +-- +-- get_queue_info() and get_queue_info(text) are granted to pgque_reader and +-- documented as reader-usable monitoring functions. They internally call +-- pgque.seq_getval(text), whose ACL is admin-only, so a SECURITY INVOKER +-- get_queue_info fails at runtime for a reader with: +-- ERROR: permission denied for function seq_getval +-- This pins the fix: get_queue_info must be callable by pgque_reader, the +-- exact role it is granted to. See +-- https://github.com/NikolayS/pgque/issues/265 + +\set ON_ERROR_STOP on + +-- Idempotent preamble. +do $$ begin + if exists (select 1 from pgque.queue where queue_name = 'gqi_reader_q') then + perform pgque.drop_queue('gqi_reader_q', true); + end if; +end $$; + +select pgque.create_queue('gqi_reader_q'); + +-- A tick must exist so the function exercises the seq_getval() code path. +select pgque.subscribe('gqi_reader_q', 'gqi_consumer'); +select pgque.send('gqi_reader_q', 'gqi.msg', '{"n":1}'::jsonb); +select pgque.ticker(); + +-- Exercise both overloads under the role they are granted to. +set role pgque_reader; + +do $$ +declare + v_count integer; + v_name text; + v_ev bigint; +begin + -- get_queue_info(text): the overload that calls seq_getval(). + select queue_name, ev_new + into v_name, v_ev + from pgque.get_queue_info('gqi_reader_q'); + assert v_name = 'gqi_reader_q', 'get_queue_info(text) must return the queue row'; + assert v_ev is not null, 'get_queue_info(text) ev_new must be populated'; + raise notice 'PASS: pgque_reader can call get_queue_info(text), ev_new=%', v_ev; + + -- get_queue_info(): the zero-arg overload fans out to the text overload. + select count(*) into v_count from pgque.get_queue_info(); + assert v_count >= 1, 'get_queue_info() must return at least the one queue'; + raise notice 'PASS: pgque_reader can call get_queue_info(), rows=%', v_count; +end $$; + +reset role; + +-- Cleanup. +select pgque.drop_queue('gqi_reader_q', true); + +\echo 'PASS: test_get_queue_info_reader'