ORCE ROW LEVEL SECURITY makes ADD/VALIDATE FOREIGN KEY skip rows hidden from the owner (RI_Initial_Check)

From: Ludwig Kannicht <info(at)luline(dot)de>
To: pgsql-bugs(at)lists(dot)postgresql(dot)org
Subject: ORCE ROW LEVEL SECURITY makes ADD/VALIDATE FOREIGN KEY skip rows hidden from the owner (RI_Initial_Check)
Date: 2026-10-25 09:34:52
Message-ID: 954644F5-A39B-4BC1-B698-56AB42F0316F@luline.de
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-bugs

Hi,

Thanks for the great work!

I think I found a bug in foreign key validation under FORCE ROW LEVEL
SECURITY. An AI assistant (Opus 5,5, by Anthropic) found it while working on
our multi-tenant schema. A second, independent AI agent reproduced it with
the generic script below, which uses no application code or data. I have
reviewed the report and rerun the script on 18.6 and 19rc1 myself. The rest
of this mail was written by the assistant.

Ludwig

---------------------------------------------------------------------------
The following was written by Opus 5.5:

PostgreSQL version: reproduced on 18.6 and 19rc1
18.6: Ubuntu 24.04.5 LTS, x86_64 (18.6-1.pgdg24.04+2)
Debian 13, aarch64 (18.6-1.pgdg13+2)
19rc1: Debian 13, aarch64 (19~rc1-1.pgdg13+1)

Summary

When a table owner adds a foreign key, or validates one created NOT VALID,
and the referencing or referenced table has ENABLE + FORCE ROW LEVEL
SECURITY, the initial validation query (RI_Initial_Check) sees only the rows
the owner's own policies expose:

- an orphan referencing row hidden by a policy is not checked, and the
constraint is marked convalidated = t although an existing row violates it;
- a referenced row hidden by a policy makes a valid referencing row fail with
"Key (...) is not present in table".

The role is neither superuser nor BYPASSRLS. Without FORCE, or as a superuser
or BYPASSRLS role, validation is correct. The RI triggers on later inserts
are correct, and so is the per-row fallback path (forced, for example, by the
owner revoking its own SELECT on the referencing table). SET ROLE, SET
SESSION AUTHORIZATION and a direct login behave the same.

This contradicts the documentation:

- 5.9 Row Security Policies: "Referential integrity checks, such as unique or
primary key constraints and foreign key references, always bypass row
security to ensure that data integrity is maintained."
- CREATE POLICY: policies "are not applied when the system is performing
internal referential integrity checks or validating constraints".

Repro (run as a superuser in a scratch database)

CREATE ROLE repro_owner NOSUPERUSER NOBYPASSRLS;
GRANT CREATE ON SCHEMA public TO repro_owner;
SET ROLE repro_owner;

CREATE TABLE parent (id int PRIMARY KEY, visible boolean NOT NULL);
CREATE TABLE child (id int PRIMARY KEY, parent_id int, visible boolean NOT NULL);
INSERT INTO parent VALUES (1, true), (2, false);
INSERT INTO child VALUES (10, 1, true), (30, 99, false); -- 30 is an orphan

ALTER TABLE child ENABLE ROW LEVEL SECURITY;
ALTER TABLE child FORCE ROW LEVEL SECURITY;
CREATE POLICY child_sel ON child FOR SELECT USING (visible);
CREATE POLICY child_ins ON child FOR INSERT WITH CHECK (true);

ALTER TABLE child ADD CONSTRAINT child_parent_fk
FOREIGN KEY (parent_id) REFERENCES parent (id);
-- expected: ERROR: ... violates foreign key constraint "child_parent_fk"
-- actual: ALTER TABLE

SELECT convalidated FROM pg_constraint WHERE conname = 'child_parent_fk';
-- actual: t

RESET ROLE;
SELECT c.* FROM child c
WHERE NOT EXISTS (SELECT 1 FROM parent p WHERE p.id = c.parent_id);
-- actual: 30 | 99 | f

The same happens with ADD ... NOT VALID followed by VALIDATE CONSTRAINT.

Reverse case: with FORCE and a policy on parent hiding parent 2, a valid
child row (20, 2, true) fails with

ERROR: insert or update on table "child" violates foreign key constraint "child_parent_fk"
DETAIL: Key (parent_id)=(2) is not present in table "parent".

With row_security = off, the owner instead gets

ERROR: query would be affected by row-level security policy for table "parent"

with the RI_Initial_Check query in the error context, which shows that the
check query is subject to the policy.

Probable cause (from reading ri_triggers.c on 18.x and master; not confirmed)

RI_Initial_Check falls back to the per-row check only when RLS is enabled
and the current user is neither the owner nor BYPASSRLS; it does not look at
relforcerowsecurity. Its query then runs through SPI_execute_snapshot as the
current user without SECURITY_NOFORCE_RLS, whereas ri_PerformCheck sets
SECURITY_LOCAL_USERID_CHANGE | SECURITY_NOFORCE_RLS.

In 2015, while FORCE ROW LEVEL SECURITY was being added, Noah Misch asked why
RI_Initial_Check hard-codes the RLS rules instead of calling
check_enable_rls(), and Stephen Frost replied that he saw no reason for it
now (message-id 20150921133015(dot)GK3685(at)tamriel(dot)snowman(dot)net, thread
"Re: row_security GUC, BYPASSRLS").

Two possible fixes: fall back to the per-row path whenever check_enable_rls()
returns RLS_ENABLED, or run the initial check query with
SECURITY_NOFORCE_RLS.

Not tested: partitioned tables, multi-column keys, MATCH FULL, roles that
are members of the owner role. Temporal (PERIOD) foreign keys skip
RI_Initial_Check and should be unaffected.

Prior reports: none found by searching the pgsql-bugs and pgsql-hackers
archives for RI_Initial_Check and FORCE ROW LEVEL SECURITY; only the 2015
exchange above touches this code.

Browse pgsql-bugs by date

  From Date Subject
Next Message Nitin Motiani 2026-10-25 11:35:39 Re: BUG #19761: btree_gist: "cannot subtract infinite dates" when inserting 'infinity' into a GiST index on date
Previous Message Zhijie Hou 2026-10-25 08:31:57 Re: BUG #19760: CLUSTER loses a row the transaction inserted and then updated in a rolled-back subtransaction