Enforce INSERT RLS checks for FOR PORTION OF leftovers?

First seen: 2026-05-01 18:53:04+00:00 · Messages: 2 · Participants: 1

Latest Update

2026-05-06 · opus 4.7

Analysis: RLS Bypass in FOR PORTION OF Leftover Inserts

Background: Temporal Tables and FOR PORTION OF

PostgreSQL's SQL:2011 temporal feature FOR PORTION OF allows UPDATE and DELETE to operate on a sub-range of a row's application-time period (e.g., a daterange column declared as the period). The semantics: when the targeted portion lies strictly inside an existing row's period, the original row is split — the targeted slice is updated/deleted, and the non-targeted "leftover" prefix and/or suffix ranges are re-inserted as new rows to preserve the rest of the period.

Mechanically, in the executor this is implemented by ExecForPortionOfLeftovers(), which synthesizes the leftover tuples and feeds them through ExecInsert(). Crucially, these inserts are internal to the statement: the user wrote UPDATE/DELETE, not INSERT. For that reason, the code path deliberately skips INSERT ACL permission checks — requiring INSERT privilege to run an UPDATE FOR PORTION OF would be a surprising and arguably wrong API.

The Core Problem

Ayush Tiwari identifies an asymmetry in how this ACL-skip was generalized to Row-Level Security (RLS). The rewriter (src/backend/rewrite/rowsecurity.c:get_row_security_policies) attaches WithCheckOptions (WCOs) based strictly on the statement's commandType. For an UPDATE or DELETE, only UPDATE/DELETE policies are gathered and only WCO_RLS_UPDATE_CHECK / USING clauses are installed on the target RTE. No WCO_RLS_INSERT_CHECK is ever produced, because the parse tree has no CMD_INSERT node.

But the leftover tuples that flow through ExecInsert() are, semantically, new rows. From an RLS policy author's perspective, any INSERT or FOR ALL policy with a WITH CHECK clause should gate them. Because no WCO_RLS_INSERT_CHECK was attached, ExecInsert()'s leftover path performs the insertion unchecked.

The reproducer makes the hole concrete:

CREATE POLICY p_all ON t FOR ALL TO u USING (true) WITH CHECK (true);
CREATE POLICY p_ins ON t FOR INSERT TO u WITH CHECK (false);

A direct INSERT is correctly rejected (the restrictive-style combination of permissive FOR ALL and FOR INSERT WITH CHECK clauses must both be satisfied on an insert — false kills it). But UPDATE ... FOR PORTION OF ..., which produces leftover inserts, silently succeeds. This is a genuine policy-enforcement gap: a user can materialize new rows that an explicit INSERT policy forbids, simply by updating a sub-range of an existing row.

Why This Matters Architecturally

RLS is a security boundary. Its guarantee to DBAs is that all row introductions into a table pass through the WITH CHECK gauntlet of applicable INSERT/ALL policies. Temporal leftovers are a new, executor-internal row-producing path introduced with FOR PORTION OF, and the RLS plumbing in the rewriter was not taught about it. The defect mirrors the classic pattern where a new executor path (like MERGE had during its development) needs explicit integration with the rewriter to pick up the right set of WCOs.

Note the important distinction the author draws with ACL: skipping INSERT privilege checks is defensible (the user is performing an UPDATE, not authoring an INSERT statement), but skipping INSERT RLS checks is not, because RLS policies express data-shape invariants independent of statement form. A WITH CHECK (false) insert policy says "no new rows matching this predicate may exist," and that invariant is violated regardless of the DML verb that produced them.

Proposed Fix

The patch sketch extends get_row_security_policies() so that when the command is UPDATE/DELETE and root->forPortionOf != NULL, it additionally:

  1. Calls get_policies_for_relation(rel, CMD_INSERT, user_id, ...) to collect INSERT permissive and restrictive policies (these include FOR ALL policies, per the usual policy-fetch semantics).
  2. Calls add_with_check_options(...) with WCO_RLS_INSERT_CHECK so that ExecInsert() (via ExecWithCheckOptions) enforces them on every leftover tuple.

An Assert(rt_index == root->resultRelation) guards the assumption that forPortionOf only applies to the target relation.

Design Considerations and Tradeoffs

Status and Open Questions

As of the second message, no committer has responded. Key questions a reviewer (likely Paul Jungwirth, the FOR PORTION OF author, or an RLS-familiar committer such as Stephen Frost or Dean Rasheed) would need to weigh in on:

  1. Is the RLS bypass a bug or an intentional mirror of the ACL-skip? The author argues — persuasively — that ACL and RLS have different semantics here.
  2. Should DELETE FOR PORTION OF leftovers also enforce DELETE policies' USING clause on the original row and INSERT WCOs on the leftovers? (The current proposal handles only the latter; the former is likely already handled by the regular DELETE path.)
  3. Test coverage: a regression test in src/test/regress/sql/rowsecurity.sql exercising the reproducer is mandatory.
  4. Backpatching: FOR PORTION OF was introduced in PG 18 (committed during the v18 cycle), so this would need backpatching to whichever releases shipped the feature.

Assessment

The analysis is correct and the fix is minimal, localized, and architecturally appropriate. This is a legitimate security-relevant defect — not a mere documentation miss — because it allows a user with UPDATE privilege to introduce rows forbidden by an INSERT WITH CHECK (false) policy. The author's instinct to treat this as an RLS-layer issue rather than an executor issue is right.