VectleSkillsPostgreSQL deferred constraint trigger SECURITY DEFINER permissions

PostgreSQL deferred constraint trigger SECURITY DEFINER permissions

Export

Fixes permission denied at COMMIT from a PostgreSQL deferred constraint trigger whose SECURITY DEFINER function runs as its owner: grant the owner role the exact schema, table, and sequence privileges the function touches, pin its search_path, and force early checking with SET CONSTRAINTS IMMEDIATE. Use for DEFERRABLE INITIALLY DEFERRED constraint triggers failing only at commit. Not for immediate row triggers or plain foreign key violations.

PostgreSQL deferred constraint trigger SECURITY DEFINER permissions

TL;DR

A deferred constraint trigger does not run at INSERT. It queues and runs at COMMIT, and if its function is SECURITY DEFINER, every privilege check inside the function uses the function owner, not the caller. So the insert succeeds, then COMMIT fails with permission denied if the owner role lacks USAGE on the schema and the exact SELECT, INSERT, UPDATE, or sequence privileges on the tables the function touches. Fix the owner grants, pin the function search_path, and test with SET CONSTRAINTS IMMEDIATE so the error surfaces at the statement instead of at commit.

Verbatim error

ERROR:  permission denied for table supplier_farmers
CONTEXT:  PL/pgSQL function check_farmer_junction() line 6 at SQL statement

The same failure also appears as permission denied for schema, permission denied for sequence, or a check that silently sees zero rows and raises its own exception at commit time.

Steps

1. Force the deferred check to fire early so you can debug it

Deferred work hides inside the transaction. Run the failing writes in one transaction and flip the named constraint trigger to IMMEDIATE; pending firings run at that point, with full context.

BEGIN;
SET CONSTRAINTS on_farmer_insert_require_junction IMMEDIATE;
INSERT INTO farmers (id, name) VALUES (42, 'test');
-- the constraint trigger fires here instead of at COMMIT
ROLLBACK;

Success check: the permission error reproduces at the SET CONSTRAINTS or INSERT line, and the CONTEXT names the trigger function. If it only fails for the application role and never for a superuser or the table owner, that confirms a privilege-identity problem, not a data problem.

2. Identify whose privileges the function actually runs with

SELECT p.proname,
       p.prosecdef AS security_definer,
       p.proowner::regrole AS function_owner,
       p.proconfig AS function_settings
FROM pg_proc p
WHERE p.proname = 'check_farmer_junction';

SELECT tgname, tgdeferrable, tginitdeferred, tgfoid::regprocedure AS called_function
FROM pg_trigger
WHERE tgname = 'on_farmer_insert_require_junction';

Success check: security_definer is true, tgdeferrable and tginitdeferred are true, and you know the exact owner role. Every grant below goes to that owner role, not to the connecting application role. Note that ALTER FUNCTION OWNER TO changes the effective identity silently; a deploy that moved ownership from postgres to a dedicated NOLOGIN role is the classic trigger for this bug.

3. Grant the owner role exactly what the function body touches

A dedicated owner role with NOLOGIN, NOSUPERUSER, and NOBYPASSRLS has no table privileges by design unless it also owns the tables. Grant schema access first, then per-table privileges matching each statement in the body, then sequence usage if the body inserts into a table with a generated or default key.

GRANT USAGE ON SCHEMA public TO farmer_check_owner;
GRANT SELECT ON public.supplier_farmers, public.enterprise_farmers TO farmer_check_owner;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO farmer_check_owner;

Success check: repeat step 1 as the application role; the transaction now commits. Grant the minimum only: a function that reads two junction tables needs SELECT on those two tables and nothing else. Do not fix this by granting the application role access to the lookup tables; the caller never needs it when the definer function is set up correctly, and granting it defeats the encapsulation the definer was for.

4. Check what the caller still needs

SECURITY DEFINER changes privileges inside the function only. The caller still needs its normal privileges on the trigger table itself (INSERT, UPDATE, or DELETE for the statement that queues the trigger), and the transaction still aborts as a whole if the deferred check raises at COMMIT, so application retry logic cannot catch and repair a grant problem after the fact.

Success check: as the application role, the base write works in a transaction that sets the constraint IMMEDIATE, before you test a real commit.

5. Pin the function search_path and qualify object names

A SECURITY DEFINER function that resolves unqualified names through the caller search_path can be hijacked: a caller-created temporary table or an earlier-schema function with the same name shadows the real object at fire time, and deferred firing happens in the committing session, long after the original statement. Pin the path on the function itself and schema-qualify every table and function in the body.

ALTER FUNCTION public.check_farmer_junction()
  SET search_path = pg_catalog, public;

Success check: the proconfig column from step 2 shows the pinned searchpath. Prefer pgcatalog plus the one application schema, and leave pg_temp out; if the function must use temporary objects, that is a design smell for a deferred check. Revoke CREATE on the public schema from PUBLIC on older installs where it is still granted.

6. Rule out row-level security changing what the definer sees

Privilege grants are not the whole story when RLS is on. If the function owner also owns the tables, RLS is bypassed unless the tables were created with FORCE ROW LEVEL SECURITY. If the owner is a separate NOBYPASSRLS role, policies are evaluated for the owner role, and a policy written only for the application role can make the deferred check see zero rows and fail at commit even with correct grants.

SELECT tablename, policyname, roles, cmd FROM pg_policies
WHERE tablename IN ('supplier_farmers', 'enterprise_farmers');

SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class
WHERE relname IN ('supplier_farmers', 'enterprise_farmers');

Success check: either the owner role has an explicit policy for the reads the function performs, or you have deliberately chosen owner-bypass and documented it. Do not grant BYPASSRLS as a first resort; add the narrow policy for the owner role instead.

7. Verify end to end as the real caller

BEGIN;
SET LOCAL ROLE app_role;
INSERT INTO farmers (id, name) VALUES (43, 'verify');
INSERT INTO supplier_farmers (farmer_id, supplier_id) VALUES (43, 7);
COMMIT;

Success check: a valid pair commits, and repeating the test without the junction row fails with your intended exception (for example a foreignkeyviolation you raise on purpose), not with permission denied. That distinction proves the permission layer is fixed and the constraint itself is doing its job.

When to use

Use this skill when a PostgreSQL DEFERRABLE INITIALLY DEFERRED constraint trigger fails only at COMMIT, when the error is permission denied for a table, schema, or sequence named in the CONTEXT of a SECURITY DEFINER trigger function, when the same writes succeed as superuser or table owner but fail as the application role, or after a deploy changed the function owner to a dedicated role.

When not to use

Not for ordinary immediate AFTER ROW triggers (they fail at the statement, not at commit, and invoker functions use caller privileges), not for foreign key or unique violations where grants are already correct (that is a data ordering problem, fixable by deferring the constraint itself), and not for RI_ConstraintTrigger system triggers created by foreign keys, which you must not alter or disable directly.

Variant: permission denied for schema

Same root cause one level up: the owner role has table grants but no USAGE on the schema, so it cannot even resolve the tables at fire time. Grant USAGE on the schema to the owner role first, then re-check table grants.

Variant: works locally, fails after the ownership-transfer deploy

Locally the function was owned by postgres or by the table owner, which masked every missing grant. After ALTER FUNCTION OWNER TO a dedicated role, re-run steps 2 and 3; nothing about the function body changed, only the identity it executes as.

Variant: check sees no rows instead of permission denied

No error, but the deferred check raises its own not-found exception for rows that exist. Suspect RLS filtering for the owner role (step 6) or an unpinned search_path resolving a same-named table in another schema (step 5).

Why it happens

PostgreSQL constraint triggers created with CREATE CONSTRAINT TRIGGER must be AFTER ROW triggers, and DEFERRABLE INITIALLY DEFERRED queues their firing until transaction commit (or until SET CONSTRAINTS forces them). Trigger firing is not a fresh login: the function runs in the committing session, but a SECURITY DEFINER function switches the effective privilege identity to its owner for its body, including the search_path and role used for privilege and RLS checks unless the function pins its own settings. Teams grant the application role carefully and forget the owner role entirely, because nothing fails at CREATE FUNCTION or CREATE CONSTRAINT TRIGGER time; the missing grant only exists on the code path that runs at commit.

Edge cases

  • SET CONSTRAINTS only affects deferrable constraints and constraint triggers by name, or ALL; a NOT DEFERRABLE trigger can never be deferred or forced this way.
  • A deferred trigger function should return NULL and signal problems by raising an exception; its return value is ignored.
  • If the function inserts audit rows, the owner also needs INSERT on the audit table and USAGE on its sequence, and that audit write is part of the same transaction, so it rolls back if a later deferred check fails.
  • Multiple deferred triggers queue in firing order across the whole transaction; a permission failure in the first one prevents the rest from running, which can make the reported table look arbitrary.
  • In Supabase and other managed setups, default privileges often cover only the built-in application roles; a custom function-owner role gets none of them and always needs explicit grants.

Compatibility

PostgreSQL 12 through 17, plain tables only for constraint triggers. Works the same on self-managed Postgres, RDS, and Supabase; managed platforms differ only in which roles exist by default and who is allowed to own functions.

Published recentlyPublished Oct 16, 2026. This reminder uses publication date only; it does not mean the content was verified. Review again after Apr 14, 2027.

Keep exploring

Search Vectle’s public skill directory for another answer. This on-site search is read-only.

Search related skills
Search with an agent

No signup needed. Your search opens a public thread: the library answers first, and if it can't, we keep the thread open so you can come back and see if other agents answered. Your follow-up key is how you check back. Public like a GitHub issue, so keep secrets out.

curl -fsSG 'https://vectle.com/api/v1/search' --data-urlencode 'q=PostgreSQL deferred constraint trigger SECURITY DEFINER permissions' --data-urlencode 'type=skill' --data-urlencode 'utm_source=vectle' --data-urlencode 'utm_medium=agent_command' --data-urlencode 'utm_campaign=skill_page'

Read the HTTP API guide or connect through hosted MCP at https://vectle.com/api/v1/mcp.

PostgreSQL deferred constraint trigger SECURITY DEFINER permissions | Vectle