Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1weTAp-004ouG-1Y for pgsql-hackers@arkaria.postgresql.org; Tue, 30 Jun 2026 07:46:27 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.96) (envelope-from ) id 1weTAo-0070Lg-0U for pgsql-hackers@arkaria.postgresql.org; Tue, 30 Jun 2026 07:46:26 +0000 Received: from magus.postgresql.org ([2a02:c0:301:0:ffff::29]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1weTAn-0070LY-2R for pgsql-hackers@lists.postgresql.org; Tue, 30 Jun 2026 07:46:25 +0000 Received: from mail-pf1-x431.google.com ([2607:f8b0:4864:20::431]) by magus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (Exim 4.98.2) (envelope-from ) id 1weTAl-00000000xUw-0TIW for pgsql-hackers@postgresql.org; Tue, 30 Jun 2026 07:46:25 +0000 Received: by mail-pf1-x431.google.com with SMTP id d2e1a72fcca58-84236f9b638so1809124b3a.2 for ; Tue, 30 Jun 2026 00:46:21 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1782805580; x=1783410380; darn=postgresql.org; h=to:cc:date:message-id:subject:mime-version :content-transfer-encoding:from:from:to:cc:subject:date:message-id :reply-to; bh=VnlL+3MX3dxIMhAkgfJzrVjXy0f3SS75VXOQVUaz/7c=; b=VxvKJuAlrgzISmWvbDZoSnTAE6vnCVu+Jvbo+vSZekQ7gYfC24LswtNHnezAYy1j30 qQPCE4V68X56JIdP5Xi2ypEQBHabKawEU5cgZtDVcjuyHyeniRN4lV9Ai5iCpiDQ7dVX pzix1nwaHLIp9M4zUCEcLuqKOulyMqx8pMLdcrygMKjKti5zOeXAC/IbgtFzu+ZdPVJ9 tIrGOxQdnyFVDmyY4gOGoF7JMiKVXo/IFBNAm7lqExcB4c6y2HagvXRUxzNyiBszWN9S YKI7RMgDkt9R/+1Os4kNolRXyP9ztkisnnow4JrGWgYn+K6guaj/NTZ+Fx+EDGGdI30u OU2w== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1782805580; x=1783410380; h=to:cc:date:message-id:subject:mime-version :content-transfer-encoding:from:x-gm-gg:x-gm-message-state:from:to :cc:subject:date:message-id:reply-to; bh=VnlL+3MX3dxIMhAkgfJzrVjXy0f3SS75VXOQVUaz/7c=; b=K1pmn777NqBh6NKo0Xz4ctqUraOWeI6gOi9RsvB0h76K1yTsJ2JurEMeeDKCKHzLub QWoNwgD2mmhrCIJr6i+9UetXCY+NcL16Jwr4sSXrE9rfbXmoxxvutNft4YlIbah84gbi 6/QK2oRuUcaqBdSDm1VOEMWwvCalgx52JZFegLi9D0cKTra5Bl4GO+fmXh7et0q0eIll 8pvSeEqigWcWX/JfLASCtFhc8k33TQ7KTJgFL+TZKjOxliH8/+dMAiT1io+q4vyWaatT IDyU5yNrH6bqx9YXqz+iFUtejB/vzmS/+bxKe7FSjzZGvh8uMxM4pYr7/Od5YgwDjoin R9tw== X-Gm-Message-State: AOJu0Yy2tmD7p08xxZZpqPefwPaZjXqZFcqNzJkX1o/UL5/D8HvMZlKK VDMeyztNEo9pgfuJ8EV1ev1kxNG8nGa1wayWbQobm04jclpxllYM+bM47XxmHg== X-Gm-Gg: AfdE7cln5FaIDLHAc+SqwOnMbDDD7jr4ToShY92+Ea0RicDujuVAXVGj8YhpJjXdIig 2mfI92viGdov2L4Oj1HOaK71khfLAluSMJJ2RMCaDj8rlejoib3WZlXnYgJknEnnU6HuBf/Qe8v R2E57LB9RKQIK3+vC+qrUr3spPbUzwseA+MzU1MG/uPj2037kVGlH+mKPiEbsUH1Ee0bRwiZFLA FFyjR/38+5jpix4O9Sr/sgQIoJZwCl6mSplLt+3O+R9vVjIZniVdbZY/kbHmBlm7cUlBtmTb8mz atCuceyN0Vs+eB9gS8/M/4RehpGGzhgnE4KzOJcgupwE93p+DO5AzJ5AAYhN0aSS5TVsInBEYIr tkAOMIPcvO2OzV2pYpBFOkLd7mUwEKO4Lh4VGNxkUZJK20U6WMRgedULkaQ1zsQbYwA8RoxH1Ow +cJPbOQVEgMc8oS2naNpyziw== X-Received: by 2002:aa7:88d6:0:b0:845:d284:9dfa with SMTP id d2e1a72fcca58-8479f2dc0b7mr1967559b3a.52.1782805579501; Tue, 30 Jun 2026 00:46:19 -0700 (PDT) Received: from smtpclient.apple ([185.135.79.125]) by smtp.gmail.com with ESMTPSA id d2e1a72fcca58-8479fcfacafsm1328180b3a.0.2026.06.30.00.46.17 (version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256 bits=128/128); Tue, 30 Jun 2026 00:46:18 -0700 (PDT) From: Chao Li Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: quoted-printable Mime-Version: 1.0 (Mac OS X Mail 16.0 \(3864.600.51.1.1\)) Subject: Fix RLS checks for UPDATE/DELETE FOR PORTION OF leftover rows Message-Id: <6C34A987-AC50-4477-BD71-2D4AFEE1A589@gmail.com> Date: Tue, 30 Jun 2026 15:45:43 +0800 Cc: Peter Eisentraut , Paul A Jungwirth To: PostgreSQL-development X-Mailer: Apple Mail (2.3864.600.51.1.1) List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk Hi, While revisiting =E2=80=9C[8e72d914c] Add UPDATE/DELETE FOR PORTION = OF=E2=80=9D, I found a new issue where inserting leftover rows may skip = row-level security checks. This is a repro: 1. Prepare a role and a table with an int4range column, then add = policies that require the lower bound to be less than 50: ``` evantest=3D# create role u; CREATE ROLE evantest=3D# create table t (id int, valid_at int4range); CREATE TABLE evantest=3D# alter table t enable row level security; ALTER TABLE evantest=3D# alter table t force row level security; ALTER TABLE evantest=3D# create policy t_sel on t for select to u using (true); CREATE POLICY evantest=3D# create policy t_upd on t for update to u using = (lower(valid_at)<50) with check (lower(valid_at)<50); CREATE POLICY evantest=3D# create policy t_del on t for delete to u using = (lower(valid_at)<50); CREATE POLICY evantest=3D# create policy t_ins on t for insert to u with check = (lower(valid_at)<50); CREATE POLICY evantest=3D# grant select, update, delete, insert on t to u; GRANT evantest=3D# insert into t values (1, '[10,100)'); INSERT 0 1 evantest=3D# set role u; SET ``` 2. Update the row: ``` evantest=3D> update t for portion of valid_at from 30 to 70 set id=3D2; UPDATE 1 evantest=3D> select * from t; id | valid_at ----+---------- 2 | [30,70) 1 | [10,30) 1 | [70,100) (3 rows) ``` Here, the right leftover [70,100) was inserted, even though the INSERT = policy requires lower(valid_at) < 50. If the policy were honored, the = update should fail because the leftover insert violates policy t_ins. After tracing the update statement, I think the problem is as follows. 1.In the rewrite phase, get_policies_for_relation() gets only the UPDATE = policy, because commandType is CMD_UPDATE: ``` /* * For SELECT, UPDATE and DELETE, add security quals to enforce = the USING * policies. These security quals control access to existing = table rows. * Restrictive policies are combined together using AND, and = permissive * policies are combined together using OR. */ get_policies_for_relation(rel, commandType, user_id, = &permissive_policies, = &restrictive_policies); =E2=80=A6.. /* * For INSERT and UPDATE, add withCheckOptions to verify that = any new * records added are consistent with the security policies. = This will use * each policy's WITH CHECK clause, or its USING clause if no = explicit * WITH CHECK clause is defined. */ if (commandType =3D=3D CMD_INSERT || commandType =3D=3D = CMD_UPDATE) { /* This should be the target relation */ Assert(rt_index =3D=3D root->resultRelation); add_with_check_options(rel, rt_index, commandType = =3D=3D CMD_INSERT ? = WCO_RLS_INSERT_CHECK : WCO_RLS_UPDATE_CHECK, = permissive_policies, = restrictive_policies, = withCheckOptions, hasSubLinks, false); ``` 2. In ExecForPortionOfLeftovers(), before inserting a leftover row, it = temporarily saves mtstate->operation and changes it to CMD_INSERT: ``` /* * Save some mtstate things so we can restore = them below. XXX: * Should we create our own ModifyTableState = instead? */ oldOperation =3D mtstate->operation; mtstate->operation =3D CMD_INSERT; oldTcs =3D mtstate->mt_transition_capture; ``` 3. Then it calls ExecInsert() with that state to insert the leftover = row: ``` /* * The standard says that each temporal leftover should = execute its * own INSERT statement, firing all statement and row = triggers, but * skipping insert permission checks. Therefore we give = each insert * its own transition table. If we just push & pop a new = trigger level * for each insert, we get exactly what we need. * * We have to make sure that the inserts don't add to = the ROW_COUNT * diagnostic or the command tag, so we pass false for = canSetTag. */ AfterTriggerBeginQuery(); ExecSetupTransitionCaptureState(mtstate, estate); fireBSTriggers(mtstate); ExecInsert(context, resultRelInfo, leftoverSlot, false, = NULL, NULL); fireASTriggers(mtstate); AfterTriggerEndQuery(estate); ``` 4. In ExecInsert(), wco_kind is chosen from mtstate->operation. Since it = is now CMD_INSERT, the value becomes WCO_RLS_INSERT_CHECK: ``` /* * Check any RLS WITH CHECK policies. * * Normally we should check INSERT policies. But if the = insert is the * result of a partition key update that moved the tuple = to a new * partition, we should instead check UPDATE policies, = because we are * executing policies defined on the target table, and = not those * defined on the child partitions. * * If we're running MERGE, we refer to the action that = we're executing * to know if we're doing an INSERT or UPDATE to a = partition table. */ if (mtstate->operation =3D=3D CMD_UPDATE) wco_kind =3D WCO_RLS_UPDATE_CHECK; else if (mtstate->operation =3D=3D CMD_MERGE) wco_kind =3D = (mtstate->mt_merge_action->mas_action->commandType =3D=3D CMD_UPDATE) ? WCO_RLS_UPDATE_CHECK : = WCO_RLS_INSERT_CHECK; else wco_kind =3D WCO_RLS_INSERT_CHECK; ``` 5. With this wco_kind, it calls ExecWithCheckOptions(): ``` /* * ExecWithCheckOptions() will skip any WCOs which are = not of the kind * we are looking for at this point. */ if (resultRelInfo->ri_WithCheckOptions !=3D NIL) ExecWithCheckOptions(wco_kind, resultRelInfo, = slot, estate); ``` 6. In ExecWithCheckOptions(), it loops over all WithCheckOptions built = during rewrite: ``` /* Check each of the constraints */ forboth(l1, resultRelInfo->ri_WithCheckOptions, l2, resultRelInfo->ri_WithCheckOptionExprs) { WithCheckOption *wco =3D (WithCheckOption *) lfirst(l1); ExprState *wcoExpr =3D (ExprState *) lfirst(l2); /* * Skip any WCOs which are not the kind we are looking = for at this * time. */ if (wco->kind !=3D kind) continue; ``` In this loop, wco->kind is WCO_RLS_UPDATE_CHECK, but kind is = WCO_RLS_INSERT_CHECK, so the check is skipped and the right leftover row = [70,100) is inserted. The same problem exists for DELETE as well: ``` evantest=3D> delete from t for portion of valid_at from 30 to 70; DELETE 1 evantest=3D> select * from t; id | valid_at ----+---------- 1 | [10,30) 1 | [70,100) (2 rows) ``` For DELETE, in code snippet 1 above, add_with_check_options() is not = called at all because commandType is CMD_DELETE. Then, in code snippet = 5, resultRelInfo->ri_WithCheckOptions is NULL, so ExecWithCheckOptions() = is not called. I checked the docs, and they seem to mention only that INSERT privilege = is not required for the hidden insertion of leftover rows. But I think = RLS should still be honored, because otherwise the policies are = violated. For example, directly inserting [70,100) is blocked by policy = t_ins, but a user can work around that by inserting [1,100) and then = updating [30,70), which seems like a security hole. To fix this, I think the rewriter should load INSERT policies for UPDATE = and DELETE operations when parse->forPortionOfexists, since = UPDATE/DELETE FOR PORTION OF may insert leftover rows. See the attached patch for details. With the fix, both the update and = delete above now fail: ``` evantest=3D> update t for portion of valid_at from 30 to 70 set id=3D2; ERROR: new row violates row-level security policy for table "t" evantest=3D> delete from t for portion of valid_at from 30 to 70; ERROR: new row violates row-level security policy for table "t" ``` BTW, I just saw that the v19 branch has been cut and HEAD is now v20, so = it looks like this patch will need to be back-patched if accepted. Best regards, -- Chao Li (Evan) HighGo Software Co., Ltd. https://www.highgo.com/