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:
- Calls
get_policies_for_relation(rel, CMD_INSERT, user_id, ...)to collectINSERTpermissive and restrictive policies (these includeFOR ALLpolicies, per the usual policy-fetch semantics). - Calls
add_with_check_options(...)withWCO_RLS_INSERT_CHECKso thatExecInsert()(viaExecWithCheckOptions) 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
- Locus of the fix (rewriter vs executor). Placing the fix in
rowsecurity.cis idiomatic: all other RLS enforcement is declared there and materialized as WCOs that the executor blindly runs. Doing it in the executor (e.g., inExecForPortionOfLeftovers) would bifurcate RLS logic and risk drift. The rewriter location is correct. - Policy set selection. Fetching
CMD_INSERTpolicies causesget_policies_for_relationto also pullFOR ALLpolicies, which is consistent with how a realINSERTis treated. This is exactly the semantic the reproducer expects. hasSubLinkspropagation. The snippet passeshasSubLinksby value;add_with_check_optionstypically takes it by pointer (bool *) to bubble up any sublinks discovered in policy expressions. A finalized patch must match the existing signature — likely a minor transcription issue in the email.- The last
falseargument in theadd_with_check_optionscall presumably corresponds to theforce_using/ treatment flag used elsewhere; the author is modeling the call on the existingCMD_INSERTbranch. - Interaction with
UPDATEWCOs already attached. For the updated (in-range) row, existing logic already attachesWCO_RLS_UPDATE_CHECK. The newWCO_RLS_INSERT_CHECKWCOs would also fire for that row if it flows throughExecInsert— butFOR PORTION OFroutes the updated portion through the normal update path, not through leftover insertion, so this should not cause double-checking. This invariant deserves explicit verification in review. - Documentation fallback. The author correctly flags that if the project decides the current behavior is intentional, the RLS/policy documentation must be amended to warn that
FOR PORTION OFleftovers bypassINSERTpolicies. Silent semantic exceptions to RLS are unacceptable from a security-documentation standpoint.
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:
- 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.
- Should
DELETE FOR PORTION OFleftovers also enforceDELETEpolicies'USINGclause on the original row andINSERTWCOs on the leftovers? (The current proposal handles only the latter; the former is likely already handled by the regularDELETEpath.) - Test coverage: a regression test in
src/test/regress/sql/rowsecurity.sqlexercising the reproducer is mandatory. - Backpatching:
FOR PORTION OFwas 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.