From bde8ff416f29255b950cb09f944f66b4f1bca611 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 15 Oct 2026 09:13:59 -0500 Subject: [PATCH] fix(ddl): replace worm_audit_purge when the owner revoked its own EXECUTE PostgreSQL refuses CREATE OR REPLACE FUNCTION when the owning role has no EXECUTE on it, which is the CNPG shape (the runtime role owns the schema and worm-audit:6 revokes its EXECUTE). worm-audit:13 now grants EXECUTE back to the migrating role only when missing, replaces the function, and revokes it again inside the same changeset transaction. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/ddl/worm-audit-postgres.sql | 18 ++++++++++++++++++ .../wormaudit/ddl/postgres/worm-audit.sql | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf0f6f..5347c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Fixed +- `worm-audit:13` failed with `permission denied for function worm_audit_purge` when the migrating role owns the function but had revoked its own EXECUTE (a runtime role that owns the schema, as on CNPG; PostgreSQL refuses `CREATE OR REPLACE` then). The changeset now grants EXECUTE back to the migrating role only if it lacks it, replaces the function, and revokes it again in the same transaction. Found by Website-Backend's CNPG-shaped changelog test. - `worm-audit:13` had a comment line starting with `-- changeset`, which Liquibase formatted-SQL parsing rejects as a malformed directive. The comment is reworded, and `CanonicalAndCryptoTest` now requires every `-- changeset` line to be a real directive. - Chain writer database load. Each 30-second tick re-read the whole late-detail lookback (24 h of `audit_detail`) and probed the GIN array index once per detail (18.4 s and 1.78M buffer hits per tick on a 120k-detail production database), and re-scanned every settled audit header for the unchained anti-join. Coverage now lives in `worm_audit_chain_detail` (`worm-audit:13`): one insert-only row per covered detail, written by the chain trigger in the same statement as its link and backfilled from the existing chain, so the late-detail check is a primary-key probe. Each tick also reads only ids above per-JVM sweep floors (rows settled since the last complete sweep); after a restart and once an hour a tick sweeps everything, as before. Link hash inputs, `detail_ids`, the verifier and exports are unchanged. - The chain trigger's overlap check (`EXISTS ... && ...`) ran as a cached generic plan that sequentially scanned all of `worm_audit_chain` for every inserted link (55k sequential scans, 1.2B tuples read in production, about 80 ms per link and growing). The trigger now inserts the coverage rows and lets their primary key reject a detail covered twice. diff --git a/docs/ddl/worm-audit-postgres.sql b/docs/ddl/worm-audit-postgres.sql index 97c0ad6..435b1fc 100644 --- a/docs/ddl/worm-audit-postgres.sql +++ b/docs/ddl/worm-audit-postgres.sql @@ -482,6 +482,16 @@ REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_chain_detail FROM "app"; GRANT SELECT, INSERT ON public.worm_audit_chain_detail TO "app"; -- Same function as worm-audit:5, except that coverage rows are locked and purged before their links. +-- The migrating role may have revoked its own EXECUTE (a runtime role that owns the schema, as on CNPG); +-- PostgreSQL then refuses CREATE OR REPLACE. Grant it back for this statement only, then restore. +DO $$ +BEGIN + IF NOT has_function_privilege(current_user, 'public.worm_audit_purge(timestamptz)', 'EXECUTE') THEN + EXECUTE format('GRANT EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) TO %I', current_user); + PERFORM set_config('worm_audit.purge_regrant', 'on', true); + END IF; +END; +$$; CREATE OR REPLACE FUNCTION public.worm_audit_purge(p_cutoff timestamptz) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp SET timezone = 'UTC' AS $fn$ DECLARE @@ -536,3 +546,11 @@ BEGIN VALUES(now(), session_user, p_cutoff, '6 years', 'PURGED', audits_count, details_count, links_count, boundary, anchor); END; $fn$; +DO $$ +BEGIN + IF current_setting('worm_audit.purge_regrant', true) = 'on' THEN + EXECUTE format('REVOKE EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) FROM %I', current_user); + PERFORM set_config('worm_audit.purge_regrant', 'off', true); + END IF; +END; +$$; diff --git a/src/main/resources/com/kingsrook/qbits/wormaudit/ddl/postgres/worm-audit.sql b/src/main/resources/com/kingsrook/qbits/wormaudit/ddl/postgres/worm-audit.sql index 8bd3596..347a6ce 100644 --- a/src/main/resources/com/kingsrook/qbits/wormaudit/ddl/postgres/worm-audit.sql +++ b/src/main/resources/com/kingsrook/qbits/wormaudit/ddl/postgres/worm-audit.sql @@ -482,6 +482,16 @@ REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_chain_detail FROM ${app_rol GRANT SELECT, INSERT ON public.worm_audit_chain_detail TO ${app_role}; -- Same function as worm-audit:5, except that coverage rows are locked and purged before their links. +-- The migrating role may have revoked its own EXECUTE (a runtime role that owns the schema, as on CNPG); +-- PostgreSQL then refuses CREATE OR REPLACE. Grant it back for this statement only, then restore. +DO $$ +BEGIN + IF NOT has_function_privilege(current_user, 'public.worm_audit_purge(timestamptz)', 'EXECUTE') THEN + EXECUTE format('GRANT EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) TO %I', current_user); + PERFORM set_config('worm_audit.purge_regrant', 'on', true); + END IF; +END; +$$; CREATE OR REPLACE FUNCTION public.worm_audit_purge(p_cutoff timestamptz) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp SET timezone = 'UTC' AS $fn$ DECLARE @@ -536,3 +546,11 @@ BEGIN VALUES(now(), session_user, p_cutoff, '${retention}', 'PURGED', audits_count, details_count, links_count, boundary, anchor); END; $fn$; +DO $$ +BEGIN + IF current_setting('worm_audit.purge_regrant', true) = 'on' THEN + EXECUTE format('REVOKE EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) FROM %I', current_user); + PERFORM set_config('worm_audit.purge_regrant', 'off', true); + END IF; +END; +$$; -- 2.54.0