From 07692eae02dbb0013f102ae23b29f8e7bfe18047 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Fri, 14 Aug 2026 13:57:26 +0300
Subject: [PATCH v5 4/4] Reject MERGE/SPLIT of partitions with row-level
 security

The new partitions created by ALTER TABLE ... MERGE/SPLIT PARTITION are built
from the partitioned table as a template, and row-level security is not part of
that template: policies are not inherited by partitions, and CREATE TABLE ...
LIKE does not copy them either.  A source partition that has row security
enabled -- or that has row security enabled with no policy at all, which denies
access outright -- was therefore replaced by a partition that restricts nothing,
silently exposing rows that were hidden until then to anyone able to query the
partition directly.

Unlike the loss of a privilege grant, which only takes access away and is
noticed immediately, this fails in the unsafe direction and is easy to miss long
after the fact.  So refuse the operation instead, and let the user re-establish
row security on the new partitions explicitly.  Policies defined while row
security is disabled hide nothing today, but they are user-written definitions
that would likewise disappear without a trace, so those are refused as well.

Only the source partitions are examined.  Row security on the partitioned table
keeps applying to queries against it, and a partition that never had row
security of its own loses nothing, so neither case is restricted.

Document this behavior and add regression coverage, including the cases that
must keep working: row security on the partitioned table alone, and partitions
without row security of their own.

Reported-by: Melanie Plageman <melanieplageman@gmail.com>
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
 doc/src/sgml/ref/alter_table.sgml             | 18 ++++++
 src/backend/commands/tablecmds.c              | 61 +++++++++++++++++++
 src/test/regress/expected/partition_merge.out | 25 ++++++++
 src/test/regress/expected/partition_split.out | 27 ++++++++
 src/test/regress/sql/partition_merge.sql      | 21 +++++++
 src/test/regress/sql/partition_split.sql      | 22 +++++++
 6 files changed, 174 insertions(+)

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0acaa23083b..009230eefae 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1314,6 +1314,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       being merged.
      </para>
 
+     <para>
+      Row-level security is likewise not carried over: the new partition is
+      built from the partitioned table, which does not pass its policies down to
+      its partitions.  A partition that has row-level security enabled, or that
+      has policies of its own, is therefore rejected, since the new partition
+      would otherwise expose rows that the merged partitions currently hide.
+      Disable row-level security and drop the policies before merging, and
+      re-establish them on the new partition afterwards.
+     </para>
 
      <para>
       Moving rows into the new partition does not emit logical replication
@@ -1465,6 +1474,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       being split.
      </para>
 
+     <para>
+      Row-level security is likewise not carried over: the new partitions are
+      built from the partitioned table, which does not pass its policies down to
+      its partitions.  A partition that has row-level security enabled, or that
+      has policies of its own, is therefore rejected, since the new partitions
+      would otherwise expose rows that the partition being split currently
+      hides.  Disable row-level security and drop the policies before splitting,
+      and re-establish them on the new partitions afterwards.
+     </para>
 
      <para>
       Moving rows into the new partitions does not emit logical replication
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 24ffe257b58..1840738f3f4 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -66,6 +66,7 @@
 #include "commands/repack.h"
 #include "commands/sequence.h"
 #include "commands/tablecmds.h"
+#include "commands/policy.h"
 #include "commands/tablespace.h"
 #include "commands/trigger.h"
 #include "commands/typecmds.h"
@@ -23448,6 +23449,54 @@ createPartitionTable(RangeVar *newPartName,
 	return newRel;
 }
 
+/*
+ * checkPartitionRowSecurity: refuse MERGE/SPLIT when a source partition has
+ * row-level security of its own.
+ *
+ * The new partitions are built from the partitioned-table template, and row
+ * security is not part of that template: it is neither inherited from the
+ * partitioned table nor copied from the source partitions (CREATE TABLE ...
+ * LIKE does not copy policies either).  A partition that restricts, or with
+ * row security enabled and no policy outright denies, direct access to its rows
+ * would therefore be replaced by one that does not, silently exposing rows that
+ * were hidden until now.  Unlike the loss of a privilege grant, which merely
+ * takes access away, this fails in the unsafe direction and is easy to miss, so
+ * refuse the operation instead and let the user re-establish row security on
+ * the new partitions explicitly.  Policies defined while row security is
+ * disabled hide nothing today, but they are user-written definitions that would
+ * likewise disappear without a trace, so those are refused as well.
+ *
+ * Only the source partitions are examined.  Row security on the partitioned
+ * table keeps applying to queries against it, and a partition that never had
+ * row security of its own does not lose any.
+ */
+static void
+checkPartitionRowSecurity(List *sourceOids)
+{
+	foreach_oid(srcOid, sourceOids)
+	{
+		Relation	src = table_open(srcOid, NoLock);
+
+		if (src->rd_rel->relrowsecurity || src->rd_rel->relforcerowsecurity)
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("cannot merge or split partition \"%s\" that has row-level security enabled",
+						   RelationGetRelationName(src)),
+					errdetail("Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides."),
+					errhint("Disable row-level security on the partition before the operation, and re-establish it on the new partition afterwards."));
+
+		if (relation_has_policies(src))
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("cannot merge or split partition \"%s\" that has row-level security policies",
+						   RelationGetRelationName(src)),
+					errdetail("The policies are not carried over to the new partition and would be silently lost."),
+					errhint("Drop the policies from the partition before the operation, and define them on the new partition afterwards."));
+
+		table_close(src, NoLock);
+	}
+}
+
 /*
  * checkPartitionSchemaPublications: refuse MERGE/SPLIT when the new partition(s)
  * would land in a schema whose FOR TABLES IN SCHEMA publications differ from
@@ -24019,6 +24068,12 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
 		table_close(mergingPartition, NoLock);
 	}
 
+	/*
+	 * Row security of the merged partitions is not carried over to the new
+	 * partition; reject rather than silently dropping it.
+	 */
+	checkPartitionRowSecurity(mergingPartitions);
+
 	/* Look up the existing relation by the new partition name. */
 	RangeVarGetAndCheckCreationNamespace(cmd->name, NoLock, &existingRelid);
 
@@ -24451,6 +24506,12 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
 
 	splitRelOid = RelationGetRelid(splitRel);
 
+	/*
+	 * Row security of the split partition is not carried over to the new
+	 * partitions; reject rather than silently dropping it.
+	 */
+	checkPartitionRowSecurity(list_make1_oid(splitRelOid));
+
 	/*
 	 * The new partitions inherit the partitioned table's generation
 	 * expressions, but rows are moved as-is; reject a split partition whose
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 10844fd9f9b..7e1aac3b44d 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1275,6 +1275,31 @@ HINT:  Create the new partition in the same schema, or publish the partitioned t
 ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
 DROP PUBLICATION pub_merge;
 DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition with row-level security of its own: it
+-- is not carried over, so the new partition would expose rows the merged
+-- partitions hide.  Policies defined while row security is disabled are
+-- rejected too, as they would be lost without a trace.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_1 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;	-- fails
+ERROR:  cannot merge or split partition "tp_0_1" that has row-level security enabled
+DETAIL:  Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides.
+HINT:  Disable row-level security on the partition before the operation, and re-establish it on the new partition afterwards.
+ALTER TABLE tp_0_1 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;	-- still fails
+ERROR:  cannot merge or split partition "tp_0_1" that has row-level security policies
+DETAIL:  The policies are not carried over to the new partition and would be silently lost.
+HINT:  Drop the policies from the partition before the operation, and define them on the new partition afterwards.
+DROP POLICY hide_secret ON tp_0_1;
+-- Row security on the partitioned table alone is fine: the partitions have
+-- none of their own, so nothing is lost.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP TABLE t;
 RESET search_path;
 --
 DROP SCHEMA partitions_merge_schema;
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index ee6fdb44b5e..98575e00119 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1840,6 +1840,33 @@ ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
    PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
 DROP PUBLICATION pub_split;
 DROP TABLE t;
+-- SPLIT PARTITION rejects a partition with row-level security of its own, for
+-- the same reason as MERGE.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+  (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+   PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));	-- fails
+ERROR:  cannot merge or split partition "tp_0_2" that has row-level security enabled
+DETAIL:  Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides.
+HINT:  Disable row-level security on the partition before the operation, and re-establish it on the new partition afterwards.
+ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+  (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+   PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));	-- still fails
+ERROR:  cannot merge or split partition "tp_0_2" that has row-level security policies
+DETAIL:  The policies are not carried over to the new partition and would be silently lost.
+HINT:  Drop the policies from the partition before the operation, and define them on the new partition afterwards.
+DROP POLICY hide_secret ON tp_0_2;
+-- Row security on the partitioned table alone is fine.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+  (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+   PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP TABLE t;
 RESET search_path;
 --
 DROP SCHEMA partition_split_schema;
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 562fcb3401b..0fcda645147 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -937,6 +937,27 @@ ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
 DROP PUBLICATION pub_merge;
 DROP TABLE t;
 
+-- MERGE PARTITIONS rejects a partition with row-level security of its own: it
+-- is not carried over, so the new partition would expose rows the merged
+-- partitions hide.  Policies defined while row security is disabled are
+-- rejected too, as they would be lost without a trace.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_1 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;	-- fails
+ALTER TABLE tp_0_1 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;	-- still fails
+DROP POLICY hide_secret ON tp_0_1;
+-- Row security on the partitioned table alone is fine: the partitions have
+-- none of their own, so nothing is lost.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP TABLE t;
+
+
 RESET search_path;
 
 --
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index db383c1ff30..e97f13f749c 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1335,6 +1335,28 @@ ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
 DROP PUBLICATION pub_split;
 DROP TABLE t;
 
+-- SPLIT PARTITION rejects a partition with row-level security of its own, for
+-- the same reason as MERGE.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+  (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+   PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));	-- fails
+ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+  (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+   PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));	-- still fails
+DROP POLICY hide_secret ON tp_0_2;
+-- Row security on the partitioned table alone is fine.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+  (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+   PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP TABLE t;
+
 RESET search_path;
 
 --
-- 
2.55.0

