From 5de6a8fe8eef0a9c59dfcdd61b3e5a8c3023a5a4 Mon Sep 17 00:00:00 2001
From: jian he <jian.universality@gmail.com>
Date: Tue, 1 Sep 2026 15:05:27 +0800
Subject: [PATCH v15 1/1] COPY FROM with RLS

Previously, COPY FROM on a table with row-level security enabled failed with
"COPY FROM not supported with row-level security".  Now it is fully supported:
the table's INSERT policies are enforced against each copied row, the same as
for INSERT.

To achieve this, CopyFrom() builds a dummy "INSERT INTO rel DEFAULT VALUES"
statement, runs it through parse analysis, the rewriter, and the planner, and
calls ExecutorStart() on the result.  The plan is never executed; it only serves
to initialize the executor state, in particular the WITH CHECK OPTIONs derived
from the table's policies, which COPY then verifies against every row with
ExecWithCheckOptions().

Portion part of RLS node processing is inside the query rewriter, so this path
cannot skip query rewriting.  Hence, unlike plain COPY FROM, which ignores rules
on the target table, COPY FROM with row-level security raises an error if the
table has any rule that would apply to an INSERT.

discussion: https://postgr.es/m/CACJufxFbmnoa5O-vL43DPTCGt6oagY4dXgKxy=rcD9-e9g0zEg@mail.gmail.com
commitfest: https://commitfest.postgresql.org/patch/6178
---
 doc/src/sgml/ref/copy.sgml                |   9 +-
 doc/src/sgml/ref/create_policy.sgml       |   2 +-
 src/backend/commands/copy.c               |  10 +-
 src/backend/commands/copyfrom.c           | 221 +++++++++++++++++-----
 src/test/regress/expected/rowsecurity.out |  80 +++++++-
 src/test/regress/sql/rowsecurity.sql      |  99 +++++++++-
 6 files changed, 355 insertions(+), 66 deletions(-)

diff --git a/doc/src/sgml/ref/copy.sgml b/doc/src/sgml/ref/copy.sgml
index b23433b2c41..4f91fa358ec 100644
--- a/doc/src/sgml/ref/copy.sgml
+++ b/doc/src/sgml/ref/copy.sgml
@@ -616,10 +616,9 @@ COPY <replaceable class="parameter">count</replaceable>
    <para>
     If row-level security is enabled for the table, the relevant
     <command>SELECT</command> policies will apply to <literal>COPY
-    <replaceable class="parameter">table</replaceable> TO</literal> statements.
-    Currently, <command>COPY FROM</command> is not supported for tables
-    with row-level security. Use equivalent <command>INSERT</command>
-    statements instead.
+    <replaceable class="parameter">table</replaceable> TO</literal> statements;
+    the relevant <command>INSERT</command> policies will apply to <literal>COPY
+    <replaceable class="parameter">table</replaceable> FROM</literal> statements.
    </para>
 
    <para>
@@ -670,6 +669,8 @@ COPY <replaceable class="parameter">count</replaceable>
    <para>
     <command>COPY FROM</command> will invoke any triggers and check
     constraints on the destination table. However, it will not invoke rules.
+    If row-level security is enabled for the table, rules on the table are
+    not supported.
    </para>
 
    <para>
diff --git a/doc/src/sgml/ref/create_policy.sgml b/doc/src/sgml/ref/create_policy.sgml
index 0a1699185c9..3602bad8596 100644
--- a/doc/src/sgml/ref/create_policy.sgml
+++ b/doc/src/sgml/ref/create_policy.sgml
@@ -504,7 +504,7 @@ CREATE POLICY <replaceable class="parameter">name</replaceable> ON <replaceable
        <entry>&mdash;</entry>
       </row>
       <row>
-       <entry><command>INSERT</command></entry>
+       <entry><command>INSERT</command> / <command>COPY ... FROM</command></entry>
        <entry>
         Check new row&nbsp;<footnote id="rls-select-priv">
          <para>
diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c
index 003b70852bb..9d018a7c7f0 100644
--- a/src/backend/commands/copy.c
+++ b/src/backend/commands/copy.c
@@ -238,8 +238,10 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
 		 *
 		 * If RLS is not enabled for this, then just fall through to the
 		 * normal non-filtering relation handling.
+		 *
+		 * COPY FROM with row-level security is handled in function CopyFrom.
 		 */
-		if (check_enable_rls(relid, InvalidOid, false) == RLS_ENABLED)
+		if (!is_from && check_enable_rls(relid, InvalidOid, false) == RLS_ENABLED)
 		{
 			SelectStmt *select;
 			ColumnRef  *cr;
@@ -247,12 +249,6 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
 			RangeVar   *from;
 			List	   *targetList = NIL;
 
-			if (is_from)
-				ereport(ERROR,
-						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-						 errmsg("COPY FROM not supported with row-level security"),
-						 errhint("Use INSERT statements instead.")));
-
 			/*
 			 * Build target list
 			 *
diff --git a/src/backend/commands/copyfrom.c b/src/backend/commands/copyfrom.c
index 3782db171be..5d6d5b856a8 100644
--- a/src/backend/commands/copyfrom.c
+++ b/src/backend/commands/copyfrom.c
@@ -40,16 +40,19 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
 #include "nodes/miscnodes.h"
 #include "optimizer/optimizer.h"
 #include "pgstat.h"
 #include "rewrite/rewriteHandler.h"
 #include "storage/fd.h"
 #include "tcop/tcopprot.h"
+#include "utils/builtins.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/portal.h"
 #include "utils/rel.h"
+#include "utils/rls.h"
 #include "utils/snapmgr.h"
 #include "utils/typcache.h"
 
@@ -783,7 +786,7 @@ CopyFrom(CopyFromState cstate)
 	ResultRelInfo *resultRelInfo;
 	ResultRelInfo *target_resultRelInfo;
 	ResultRelInfo *prevResultRelInfo = NULL;
-	EState	   *estate = CreateExecutorState(); /* for ExecConstraints() */
+	EState	   *estate;
 	ModifyTableState *mtstate;
 	ExprContext *econtext;
 	TupleTableSlot *singleslot = NULL;
@@ -801,6 +804,7 @@ CopyFrom(CopyFromState cstate)
 	bool		has_before_insert_row_trig;
 	bool		has_instead_insert_row_trig;
 	bool		leafpart_use_multi_insert = false;
+	QueryDesc  *queryDesc = NULL;
 
 	Assert(cstate->rel);
 	Assert(list_length(cstate->range_table) == 1);
@@ -910,33 +914,121 @@ CopyFrom(CopyFromState cstate)
 		ti_options |= TABLE_INSERT_FROZEN;
 	}
 
-	/*
-	 * We need a ResultRelInfo so we can use the regular executor's
-	 * index-entry-making machinery.  (There used to be a huge amount of code
-	 * here that basically duplicated execUtils.c ...)
-	 */
-	ExecInitRangeTable(estate, cstate->range_table, cstate->rteperminfos,
-					   bms_make_singleton(1));
-	resultRelInfo = target_resultRelInfo = makeNode(ResultRelInfo);
-	ExecInitResultRelation(estate, resultRelInfo, 1);
-
-	/* Verify the named relation is a valid target for INSERT */
-	CheckValidResultRel(resultRelInfo, CMD_INSERT, ONCONFLICT_NONE, NIL, NULL);
+	if (check_enable_rls(RelationGetRelid(cstate->rel), InvalidOid, false) == RLS_ENABLED)
+	{
+		Query	   *query;
+		RawStmt    *raw_query;
+		char	   *query_string;
+		PlannedStmt *plan;
+		RangeVar   *from;
+		InsertStmt *insertstmt;
+		List	   *rewritten;
+		char	   *namespace;
 
+		namespace = get_namespace_name(RelationGetNamespace(cstate->rel));
+
+		from = makeRangeVar(namespace,
+							pstrdup(RelationGetRelationName(cstate->rel)),
+							-1);
+		from->inh = false;
+
+		insertstmt = makeNode(InsertStmt);
+		insertstmt->relation = from;
+
+		raw_query = makeNode(RawStmt);
+		raw_query->stmt = (Node *) insertstmt;
+		raw_query->stmt_location = -1;
+		raw_query->stmt_len = 0;
+
+		query_string = psprintf("INSERT INTO %s DEFAULT VALUES",
+								quote_qualified_identifier(namespace,
+														   RelationGetRelationName(cstate->rel)));
+
+		/*
+		 * Run parse analysis and rewrite.  RLS is applied during query
+		 * rewrite, so it cannot be skipped here.  Note this also acquires
+		 * sufficient locks on the table(s) involved.
+		 */
+		rewritten = pg_analyze_and_rewrite_fixedparams(raw_query,
+													   query_string,
+													   NULL,
+													   0,
+													   NULL);
+		if (rewritten == NIL)
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("DO INSTEAD NOTHING rules are not supported for COPY FROM with row-level security"));
+		else if (list_length(rewritten) > 1)
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("rules with additional commands are not supported for COPY FROM with row-level security"));
+
+		query = linitial_node(Query, rewritten);
+
+		if (query->querySource != QSRC_ORIGINAL)
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("DO INSTEAD rules are not supported for COPY FROM with row-level security"));
+
+		/* plan the query */
+		plan = pg_plan_query(query, query_string, 0, NULL, NULL);
+
+		/* Create a QueryDesc requesting no output */
+		queryDesc = CreateQueryDesc(plan,
+									query_string,
+									GetActiveSnapshot(),
+									InvalidSnapshot,
+									None_Receiver,
+									NULL,
+									NULL,
+									0);
+
+		/*
+		 * Call ExecutorStart to build the executor state; the plan is never
+		 * run, as COPY supplies the rows itself.  This initializes the
+		 * ResultRelInfo, including the ri_WithCheckOptions needed by
+		 * ExecWithCheckOptions() to check RLS policies against copied rows.
+		 */
+		ExecutorStart(queryDesc, 0);
+
+		estate = queryDesc->estate;
+		mtstate = (ModifyTableState *) queryDesc->planstate;
+		resultRelInfo = target_resultRelInfo = mtstate->resultRelInfo;
+	}
+	else
+	{
+		estate = CreateExecutorState(); /* for ExecConstraints() */
+
+		/* Prepare to catch AFTER triggers. */
+		AfterTriggerBeginQuery();
+
+		/*
+		 * We need a ResultRelInfo so we can use the regular executor's
+		 * index-entry-making machinery.  (There used to be a huge amount of
+		 * code here that basically duplicated execUtils.c ...)
+		 */
+		ExecInitRangeTable(estate, cstate->range_table, cstate->rteperminfos,
+						   bms_make_singleton(1));
+		resultRelInfo = target_resultRelInfo = makeNode(ResultRelInfo);
+		ExecInitResultRelation(estate, resultRelInfo, 1);
+
+		/* Verify the named relation is a valid target for INSERT */
+		CheckValidResultRel(resultRelInfo, CMD_INSERT, ONCONFLICT_NONE, NIL, NULL);
+
+		/*
+		 * Set up a ModifyTableState so we can let FDW(s) init themselves for
+		 * foreign-table result relation(s).
+		 */
+		mtstate = makeNode(ModifyTableState);
+		mtstate->ps.plan = NULL;
+		mtstate->ps.state = estate;
+		mtstate->operation = CMD_INSERT;
+		mtstate->mt_nrels = 1;
+		mtstate->resultRelInfo = resultRelInfo;
+		mtstate->rootResultRelInfo = resultRelInfo;
+	}
 	ExecOpenIndices(resultRelInfo, false);
 
-	/*
-	 * Set up a ModifyTableState so we can let FDW(s) init themselves for
-	 * foreign-table result relation(s).
-	 */
-	mtstate = makeNode(ModifyTableState);
-	mtstate->ps.plan = NULL;
-	mtstate->ps.state = estate;
-	mtstate->operation = CMD_INSERT;
-	mtstate->mt_nrels = 1;
-	mtstate->resultRelInfo = resultRelInfo;
-	mtstate->rootResultRelInfo = resultRelInfo;
-
 	if (resultRelInfo->ri_FdwRoutine != NULL &&
 		resultRelInfo->ri_FdwRoutine->BeginForeignInsert != NULL)
 		resultRelInfo->ri_FdwRoutine->BeginForeignInsert(mtstate,
@@ -959,8 +1051,7 @@ CopyFrom(CopyFromState cstate)
 
 	Assert(resultRelInfo->ri_BatchSize >= 1);
 
-	/* Prepare to catch AFTER triggers. */
-	AfterTriggerBeginQuery();
+	proute = mtstate->mt_partition_tuple_routing;
 
 	/*
 	 * If there are any triggers with transition tables on the named relation,
@@ -970,16 +1061,20 @@ CopyFrom(CopyFromState cstate)
 	 * transition capture is active, we also set it in mtstate, which is
 	 * passed to ExecFindPartition() below.
 	 */
-	cstate->transition_capture = mtstate->mt_transition_capture =
-		MakeTransitionCaptureState(cstate->rel->trigdesc,
-								   RelationGetRelid(cstate->rel),
-								   CMD_INSERT);
+	if (mtstate->mt_transition_capture == NULL)
+		cstate->transition_capture = mtstate->mt_transition_capture =
+			MakeTransitionCaptureState(cstate->rel->trigdesc,
+									   RelationGetRelid(cstate->rel),
+									   CMD_INSERT);
+	else
+		cstate->transition_capture = mtstate->mt_transition_capture;
 
 	/*
 	 * If the named relation is a partitioned table, initialize state for
 	 * CopyFrom tuple routing.
 	 */
-	if (cstate->rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+	if (cstate->rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE &&
+		proute == NULL)
 		proute = ExecSetupPartitionTupleRouting(estate, cstate->rel);
 
 	if (cstate->whereClause)
@@ -1351,6 +1446,20 @@ CopyFrom(CopyFromState cstate)
 					ExecComputeStoredGenerated(resultRelInfo, estate, myslot,
 											   CMD_INSERT);
 
+				/*
+				 * We suppress error context information other than the
+				 * relation name, if row level security policy check fails.
+				 */
+				Assert(!cstate->relname_only);
+				cstate->relname_only = true;
+
+				if (resultRelInfo->ri_WithCheckOptions != NIL)
+					ExecWithCheckOptions(WCO_RLS_INSERT_CHECK, resultRelInfo,
+										 myslot, estate);
+
+				/* reset relname_only */
+				cstate->relname_only = false;
+
 				/*
 				 * If the target is a plain table, check the constraints of
 				 * the tuple.
@@ -1490,30 +1599,42 @@ CopyFrom(CopyFromState cstate)
 	/* Execute AFTER STATEMENT insertion triggers */
 	ExecASInsertTriggers(estate, target_resultRelInfo, cstate->transition_capture);
 
-	/* Handle queued AFTER triggers */
-	AfterTriggerEndQuery(estate);
-
-	ExecResetTupleTable(estate->es_tupleTable, false);
-
-	/* Allow the FDW to shut down */
-	if (target_resultRelInfo->ri_FdwRoutine != NULL &&
-		target_resultRelInfo->ri_FdwRoutine->EndForeignInsert != NULL)
-		target_resultRelInfo->ri_FdwRoutine->EndForeignInsert(estate,
-															  target_resultRelInfo);
-
 	/* Tear down the multi-insert buffer data */
 	if (insertMethod != CIM_SINGLE)
 		CopyMultiInsertInfoCleanup(&multiInsertInfo);
 
-	/* Close all the partitioned tables, leaf partitions, and their indices */
-	if (proute)
-		ExecCleanupTupleRouting(mtstate, proute);
+	if (queryDesc == NULL)
+	{
+		/* Handle queued AFTER triggers */
+		AfterTriggerEndQuery(estate);
 
-	/* Close the result relations, including any trigger target relations */
-	ExecCloseResultRelations(estate);
-	ExecCloseRangeTableRelations(estate);
+		ExecResetTupleTable(estate->es_tupleTable, false);
 
-	FreeExecutorState(estate);
+		/* Allow the FDW to shut down */
+		if (target_resultRelInfo->ri_FdwRoutine != NULL &&
+			target_resultRelInfo->ri_FdwRoutine->EndForeignInsert != NULL)
+			target_resultRelInfo->ri_FdwRoutine->EndForeignInsert(estate,
+																  target_resultRelInfo);
+
+		/*
+		 * Close all the partitioned tables, leaf partitions, and their
+		 * indices
+		 */
+		if (proute)
+			ExecCleanupTupleRouting(mtstate, proute);
+
+		/* Close the result relations, including any trigger target relations */
+		ExecCloseResultRelations(estate);
+		ExecCloseRangeTableRelations(estate);
+
+		FreeExecutorState(estate);
+	}
+	else
+	{
+		ExecutorFinish(queryDesc);
+		ExecutorEnd(queryDesc);
+		FreeQueryDesc(queryDesc);
+	}
 
 	return processed;
 }
diff --git a/src/test/regress/expected/rowsecurity.out b/src/test/regress/expected/rowsecurity.out
index 356f118a366..19ff0e9519f 100644
--- a/src/test/regress/expected/rowsecurity.out
+++ b/src/test/regress/expected/rowsecurity.out
@@ -138,6 +138,10 @@ NOTICE:  SELECT USING on rls_test_src.(1,"src a")
 -- plain INSERT should apply INSERT CHECK policy clause
 INSERT INTO rls_test_tgt VALUES (1, 'tgt a');
 NOTICE:  INSERT CHECK on rls_test_tgt.(1,"tgt a","TGT A")
+TRUNCATE rls_test_tgt;
+-- COPY FROM should also apply INSERT CHECK policy clause
+COPY rls_test_tgt FROM STDIN WITH (DELIMITER ',');
+NOTICE:  INSERT CHECK on rls_test_tgt.(1,"tgt a","TGT A")
 -- INSERT ... RETURNING should also apply SELECT USING policy clause
 TRUNCATE rls_test_tgt;
 INSERT INTO rls_test_tgt VALUES (1, 'tgt a') RETURNING *;
@@ -708,9 +712,15 @@ EXPLAIN (COSTS OFF) SELECT * FROM document NATURAL JOIN category WHERE f_leak(dt
 -- back from p1r for this because it sorts first
 INSERT INTO document VALUES (100, 44, 1, 'regress_rls_dave', 'testing sorting of policies'); -- fail
 ERROR:  new row violates row-level security policy "p1r" for table "document"
+COPY document FROM STDIN WITH (DELIMITER ','); -- fail
+ERROR:  new row violates row-level security policy "p1r" for table "document"
+CONTEXT:  COPY document
 -- Just to see a p2r error
 INSERT INTO document VALUES (100, 55, 1, 'regress_rls_dave', 'testing sorting of policies'); -- fail
 ERROR:  new row violates row-level security policy "p2r" for table "document"
+COPY document FROM STDIN WITH (DELIMITER ','); -- fail
+ERROR:  new row violates row-level security policy "p2r" for table "document"
+CONTEXT:  COPY document
 -- only owner can change policies
 ALTER POLICY p1 ON document USING (true);    --fail
 ERROR:  must be owner of table document
@@ -829,6 +839,9 @@ INSERT INTO document VALUES (11, 33, 1, current_user, 'hoge');
 SET SESSION AUTHORIZATION regress_rls_bob;
 INSERT INTO document VALUES (8, 44, 1, 'regress_rls_bob', 'my third manga'); -- Must fail with unique violation, revealing presence of did we can't see
 ERROR:  duplicate key value violates unique constraint "document_pkey"
+COPY document FROM STDIN WITH (DELIMITER ','); -- fail, COPY is equivalent to INSERT
+ERROR:  duplicate key value violates unique constraint "document_pkey"
+CONTEXT:  COPY document, line 1
 SELECT * FROM document WHERE did = 8; -- and confirm we can't see it
  did | cid | dlevel | dauthor | dtitle 
 -----+-----+--------+---------+--------
@@ -837,6 +850,9 @@ SELECT * FROM document WHERE did = 8; -- and confirm we can't see it
 -- RLS policies are checked before constraints
 INSERT INTO document VALUES (8, 44, 1, 'regress_rls_carol', 'my third manga'); -- Should fail with RLS check violation, not duplicate key violation
 ERROR:  new row violates row-level security policy for table "document"
+COPY document FROM STDIN WITH (DELIMITER ','); -- fail, COPY is equivalent to INSERT
+ERROR:  new row violates row-level security policy for table "document"
+CONTEXT:  COPY document
 UPDATE document SET did = 8, dauthor = 'regress_rls_carol' WHERE did = 5; -- Should fail with RLS check violation, not duplicate key violation
 ERROR:  new row violates row-level security policy for table "document"
 -- database superuser does bypass RLS policy when enabled
@@ -1445,15 +1461,28 @@ EXPLAIN (COSTS OFF) SELECT * FROM part_document WHERE f_leak(dtitle);
 -- pp1 ERROR
 INSERT INTO part_document VALUES (100, 11, 5, 'regress_rls_dave', 'testing pp1'); -- fail
 ERROR:  new row violates row-level security policy for table "part_document"
+COPY part_document(did, cid, dlevel, dauthor, dtitle) FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+ERROR:  new row violates row-level security policy for table "part_document"
+CONTEXT:  COPY part_document
 -- pp1r ERROR
 INSERT INTO part_document VALUES (100, 99, 1, 'regress_rls_dave', 'testing pp1r'); -- fail
 ERROR:  new row violates row-level security policy "pp1r" for table "part_document"
+COPY part_document(did, cid, dlevel, dauthor, dtitle) FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+ERROR:  new row violates row-level security policy "pp1r" for table "part_document"
+CONTEXT:  COPY part_document
 -- Show that RLS policy does not apply for direct inserts to children
 -- This should fail with RLS POLICY pp1r violation.
 INSERT INTO part_document VALUES (100, 55, 1, 'regress_rls_dave', 'testing RLS with partitions'); -- fail
 ERROR:  new row violates row-level security policy "pp1r" for table "part_document"
+COPY part_document FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+ERROR:  new row violates row-level security policy "pp1r" for table "part_document"
+CONTEXT:  COPY part_document
 -- But this should succeed.
 INSERT INTO part_document_satire VALUES (100, 55, 1, 'regress_rls_dave', 'testing RLS with partitions'); -- success
+-- COPY FROM should also succeed.
+BEGIN;
+COPY part_document_satire FROM STDIN WITH (DELIMITER ',');
+ROLLBACK;
 -- We still cannot see the row using the parent
 SELECT * FROM part_document WHERE f_leak(dtitle) ORDER BY did;
 NOTICE:  f_leak => my first novel
@@ -1489,6 +1518,9 @@ CREATE POLICY pp3 ON part_document_satire AS RESTRICTIVE
 SET SESSION AUTHORIZATION regress_rls_dave;
 INSERT INTO part_document_satire VALUES (101, 55, 1, 'regress_rls_dave', 'testing RLS with partitions'); -- fail
 ERROR:  new row violates row-level security policy for table "part_document_satire"
+COPY part_document_satire FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+ERROR:  new row violates row-level security policy for table "part_document_satire"
+CONTEXT:  COPY part_document_satire
 -- And now we cannot see directly into the partition either, due to RLS
 SELECT * FROM part_document_satire WHERE f_leak(dtitle) ORDER BY did;
  did | cid | dlevel | dauthor | dtitle 
@@ -1709,6 +1741,14 @@ CREATE POLICY pp3 ON part_document AS RESTRICTIVE
 SET SESSION AUTHORIZATION regress_rls_carol;
 INSERT INTO part_document VALUES (100, 11, 5, 'regress_rls_carol', 'testing pp3'); -- fail
 ERROR:  new row violates row-level security policy "pp3" for table "part_document"
+COPY part_document FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+ERROR:  new row violates row-level security policy "pp3" for table "part_document"
+CONTEXT:  COPY part_document
+SET SESSION AUTHORIZATION regress_rls_bob;
+BEGIN;
+INSERT INTO part_document VALUES (1, 11, 1, 'regress_rls_bob', 'my second novel'); -- ok
+COPY part_document(did, cid, dlevel, dauthor, dtitle) FROM STDIN WITH (DELIMITER ','); -- ok
+ROLLBACK;
 ----- Dependencies -----
 SET SESSION AUTHORIZATION regress_rls_alice;
 SET row_security TO ON;
@@ -1788,6 +1828,7 @@ INSERT INTO s1 (SELECT x, public.fipshash(x::text) FROM generate_series(-10,10)
 CREATE TABLE s2 (x int, y text);
 INSERT INTO s2 (SELECT x, public.fipshash(x::text) FROM generate_series(-6,6) x);
 GRANT SELECT ON s1, s2 TO regress_rls_bob;
+GRANT INSERT ON s1 TO regress_rls_bob;
 CREATE POLICY p1 ON s1 USING (a in (select x from s2 where y like '%2f%'));
 CREATE POLICY p2 ON s2 USING (x in (select a from s1 where b like '%22%'));
 CREATE POLICY p3 ON s1 FOR INSERT WITH CHECK (a = (SELECT a FROM s1));
@@ -1799,6 +1840,8 @@ SELECT * FROM s1 WHERE f_leak(b); -- fail (infinite recursion)
 ERROR:  infinite recursion detected in policy for relation "s1"
 INSERT INTO s1 VALUES (1, 'foo'); -- fail (infinite recursion)
 ERROR:  infinite recursion detected in policy for relation "s1"
+COPY s1 FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+ERROR:  infinite recursion detected in policy for relation "s1"
 SET SESSION AUTHORIZATION regress_rls_alice;
 DROP POLICY p3 on s1;
 ALTER POLICY p2 ON s2 USING (x % 2 = 0);
@@ -2302,6 +2345,7 @@ NOTICE:  f_leak => 4a44dc15364204a80fe80e9039455cc1
  10 | 4a44dc15364204a80fe80e9039455cc1
 (5 rows)
 
+-- COPY FROM is not supported for views, so no COPY FROM tests are needed here.
 INSERT INTO bv1 VALUES (-1, 'xxx'); -- should fail view WCO
 ERROR:  new row violates row-level security policy for table "b1"
 INSERT INTO bv1 VALUES (11, 'xxx'); -- should fail RLS check
@@ -4221,9 +4265,10 @@ SET row_security TO OFF;
 COPY copy_t FROM STDIN; --fail - would be affected by RLS.
 ERROR:  query would be affected by row-level security policy for table "copy_t"
 SET row_security TO ON;
-COPY copy_t FROM STDIN; --fail - COPY FROM not supported by RLS.
-ERROR:  COPY FROM not supported with row-level security
-HINT:  Use INSERT statements instead.
+COPY copy_t FROM STDIN WITH (DELIMITER ','); -- ok
+COPY copy_t FROM STDIN WITH (DELIMITER ','); -- error
+ERROR:  new row violates row-level security policy for table "copy_t"
+CONTEXT:  COPY copy_t
 -- Check COPY FROM as user with permissions and BYPASSRLS
 SET SESSION AUTHORIZATION regress_rls_exempt_user;
 SET row_security TO ON;
@@ -4240,6 +4285,22 @@ RESET SESSION AUTHORIZATION;
 DROP TABLE copy_t;
 DROP TABLE copy_rel_to CASCADE;
 NOTICE:  drop cascades to table copy_rel_to_child
+-- COPY FROM with multiple policies
+RESET SESSION AUTHORIZATION;
+CREATE TABLE copy_t (a integer, b text);
+CREATE POLICY p1 ON copy_t USING (a % 2 = 0) WITH CHECK (a > 8);
+CREATE POLICY p2 ON copy_t WITH CHECK (a > 10);
+ALTER TABLE copy_t ENABLE ROW LEVEL SECURITY;
+SET row_security TO ON;
+GRANT ALL ON copy_t TO regress_rls_bob, regress_rls_exempt_user;
+SET SESSION AUTHORIZATION regress_rls_bob;
+COPY copy_t FROM STDIN WITH (DELIMITER ','); -- fail
+ERROR:  new row violates row-level security policy for table "copy_t"
+CONTEXT:  COPY copy_t
+COPY copy_t FROM STDIN WITH (DELIMITER ',', ON_ERROR IGNORE); -- ok
+NOTICE:  1 row was skipped due to data type incompatibility
+RESET SESSION AUTHORIZATION;
+DROP TABLE copy_t;
 -- Check WHERE CURRENT OF
 SET SESSION AUTHORIZATION regress_rls_alice;
 CREATE TABLE current_check (currentid int, payload text, rlsuser text);
@@ -4543,6 +4604,9 @@ SELECT * FROM r2;
 -- r2 is read-only
 INSERT INTO r2 VALUES (2); -- Not allowed
 ERROR:  new row violates row-level security policy for table "r2"
+COPY r2 FROM STDIN WITH (DELIMITER ','); -- fail
+ERROR:  new row violates row-level security policy for table "r2"
+CONTEXT:  COPY r2
 UPDATE r2 SET a = 2 RETURNING *; -- Updates nothing
  a 
 ---
@@ -4611,6 +4675,9 @@ TABLE r1;
 -- RLS error
 INSERT INTO r1 VALUES (1);
 ERROR:  new row violates row-level security policy for table "r1"
+COPY r1 FROM STDIN WITH (DELIMITER ','); -- fail
+ERROR:  new row violates row-level security policy for table "r1"
+CONTEXT:  COPY r1
 -- No error (unable to see any rows to update)
 UPDATE r1 SET a = 1;
 TABLE r1;
@@ -4739,6 +4806,7 @@ ALTER TABLE r1 ENABLE ROW LEVEL SECURITY;
 ALTER TABLE r1 FORCE ROW LEVEL SECURITY;
 -- Works fine
 INSERT INTO r1 VALUES (10), (20);
+COPY r1 FROM STDIN; -- ok
 -- No error, but no rows
 TABLE r1;
  a 
@@ -4820,9 +4888,15 @@ ALTER TABLE r1 FORCE ROW LEVEL SECURITY;
 -- Should fail p1
 INSERT INTO r1 VALUES (0);
 ERROR:  new row violates row-level security policy "p1" for table "r1"
+COPY r1 FROM STDIN WITH (DELIMITER ','); -- fail
+ERROR:  new row violates row-level security policy "p1" for table "r1"
+CONTEXT:  COPY r1
 -- Should fail p2
 INSERT INTO r1 VALUES (4);
 ERROR:  new row violates row-level security policy "p2" for table "r1"
+COPY r1 FROM STDIN WITH (DELIMITER ','); -- fail
+ERROR:  new row violates row-level security policy "p2" for table "r1"
+CONTEXT:  COPY r1
 -- OK
 INSERT INTO r1 VALUES (3);
 SELECT * FROM r1;
diff --git a/src/test/regress/sql/rowsecurity.sql b/src/test/regress/sql/rowsecurity.sql
index 99eaba28f5a..415f1f54c46 100644
--- a/src/test/regress/sql/rowsecurity.sql
+++ b/src/test/regress/sql/rowsecurity.sql
@@ -117,6 +117,11 @@ SELECT * FROM rls_test_src FOR KEY SHARE;
 
 -- plain INSERT should apply INSERT CHECK policy clause
 INSERT INTO rls_test_tgt VALUES (1, 'tgt a');
+TRUNCATE rls_test_tgt;
+-- COPY FROM should also apply INSERT CHECK policy clause
+COPY rls_test_tgt FROM STDIN WITH (DELIMITER ',');
+1,tgt a,
+\.
 
 -- INSERT ... RETURNING should also apply SELECT USING policy clause
 TRUNCATE rls_test_tgt;
@@ -334,8 +339,15 @@ EXPLAIN (COSTS OFF) SELECT * FROM document NATURAL JOIN category WHERE f_leak(dt
 -- 44 would technically fail for both p2r and p1r, but we should get an error
 -- back from p1r for this because it sorts first
 INSERT INTO document VALUES (100, 44, 1, 'regress_rls_dave', 'testing sorting of policies'); -- fail
+COPY document FROM STDIN WITH (DELIMITER ','); -- fail
+100,44,1,regress_rls_dave,testing sorting of policies
+\.
+
 -- Just to see a p2r error
 INSERT INTO document VALUES (100, 55, 1, 'regress_rls_dave', 'testing sorting of policies'); -- fail
+COPY document FROM STDIN WITH (DELIMITER ','); -- fail
+100,55,1,regress_rls_dave,testing sorting of policies
+\.
 
 -- only owner can change policies
 ALTER POLICY p1 ON document USING (true);    --fail
@@ -379,10 +391,16 @@ INSERT INTO document VALUES (11, 33, 1, current_user, 'hoge');
 -- UNIQUE or PRIMARY KEY constraint violation DOES reveal presence of row
 SET SESSION AUTHORIZATION regress_rls_bob;
 INSERT INTO document VALUES (8, 44, 1, 'regress_rls_bob', 'my third manga'); -- Must fail with unique violation, revealing presence of did we can't see
+COPY document FROM STDIN WITH (DELIMITER ','); -- fail, COPY is equivalent to INSERT
+8,44,1,regress_rls_bob,my third manga
+\.
 SELECT * FROM document WHERE did = 8; -- and confirm we can't see it
 
 -- RLS policies are checked before constraints
 INSERT INTO document VALUES (8, 44, 1, 'regress_rls_carol', 'my third manga'); -- Should fail with RLS check violation, not duplicate key violation
+COPY document FROM STDIN WITH (DELIMITER ','); -- fail, COPY is equivalent to INSERT
+8,44,1,regress_rls_carol,my third manga
+\.
 UPDATE document SET did = 8, dauthor = 'regress_rls_carol' WHERE did = 5; -- Should fail with RLS check violation, not duplicate key violation
 
 -- database superuser does bypass RLS policy when enabled
@@ -569,14 +587,32 @@ EXPLAIN (COSTS OFF) SELECT * FROM part_document WHERE f_leak(dtitle);
 
 -- pp1 ERROR
 INSERT INTO part_document VALUES (100, 11, 5, 'regress_rls_dave', 'testing pp1'); -- fail
+COPY part_document(did, cid, dlevel, dauthor, dtitle) FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+100,11,5,regress_rls_dave,testing pp1
+\.
 -- pp1r ERROR
 INSERT INTO part_document VALUES (100, 99, 1, 'regress_rls_dave', 'testing pp1r'); -- fail
+COPY part_document(did, cid, dlevel, dauthor, dtitle) FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+100,99,1,regress_rls_dave,testing pp1r
+\.
 
 -- Show that RLS policy does not apply for direct inserts to children
 -- This should fail with RLS POLICY pp1r violation.
 INSERT INTO part_document VALUES (100, 55, 1, 'regress_rls_dave', 'testing RLS with partitions'); -- fail
+COPY part_document FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+100,55,1,regress_rls_dave,testing RLS with partitions
+\.
+
 -- But this should succeed.
 INSERT INTO part_document_satire VALUES (100, 55, 1, 'regress_rls_dave', 'testing RLS with partitions'); -- success
+
+-- COPY FROM should also succeed.
+BEGIN;
+COPY part_document_satire FROM STDIN WITH (DELIMITER ',');
+100, 55, 1, regress_rls_dave, testing RLS with partitions
+\.
+ROLLBACK;
+
 -- We still cannot see the row using the parent
 SELECT * FROM part_document WHERE f_leak(dtitle) ORDER BY did;
 -- But we can if we look directly
@@ -590,6 +626,9 @@ CREATE POLICY pp3 ON part_document_satire AS RESTRICTIVE
 -- This should fail with RLS violation now.
 SET SESSION AUTHORIZATION regress_rls_dave;
 INSERT INTO part_document_satire VALUES (101, 55, 1, 'regress_rls_dave', 'testing RLS with partitions'); -- fail
+COPY part_document_satire FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+101,55,1,regress_rls_dave,testing RLS with partitions
+\.
 -- And now we cannot see directly into the partition either, due to RLS
 SELECT * FROM part_document_satire WHERE f_leak(dtitle) ORDER BY did;
 -- The parent looks same as before
@@ -651,6 +690,17 @@ CREATE POLICY pp3 ON part_document AS RESTRICTIVE
 
 SET SESSION AUTHORIZATION regress_rls_carol;
 INSERT INTO part_document VALUES (100, 11, 5, 'regress_rls_carol', 'testing pp3'); -- fail
+COPY part_document FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+100,11,5,regress_rls_carol,testing pp3
+\.
+
+SET SESSION AUTHORIZATION regress_rls_bob;
+BEGIN;
+INSERT INTO part_document VALUES (1, 11, 1, 'regress_rls_bob', 'my second novel'); -- ok
+COPY part_document(did, cid, dlevel, dauthor, dtitle) FROM STDIN WITH (DELIMITER ','); -- ok
+1,11,1,regress_rls_bob,my second novel
+\.
+ROLLBACK;
 
 ----- Dependencies -----
 SET SESSION AUTHORIZATION regress_rls_alice;
@@ -733,6 +783,7 @@ CREATE TABLE s2 (x int, y text);
 INSERT INTO s2 (SELECT x, public.fipshash(x::text) FROM generate_series(-6,6) x);
 
 GRANT SELECT ON s1, s2 TO regress_rls_bob;
+GRANT INSERT ON s1 TO regress_rls_bob;
 
 CREATE POLICY p1 ON s1 USING (a in (select x from s2 where y like '%2f%'));
 CREATE POLICY p2 ON s2 USING (x in (select a from s1 where b like '%22%'));
@@ -746,6 +797,9 @@ CREATE VIEW v2 AS SELECT * FROM s2 WHERE y like '%af%';
 SELECT * FROM s1 WHERE f_leak(b); -- fail (infinite recursion)
 
 INSERT INTO s1 VALUES (1, 'foo'); -- fail (infinite recursion)
+COPY s1 FROM STDIN WITH (DELIMITER ','); -- fail, COPY FROM is equivalent to INSERT
+1,foo
+\.
 
 SET SESSION AUTHORIZATION regress_rls_alice;
 DROP POLICY p3 on s1;
@@ -877,6 +931,7 @@ SET SESSION AUTHORIZATION regress_rls_carol;
 EXPLAIN (COSTS OFF) SELECT * FROM bv1 WHERE f_leak(b);
 SELECT * FROM bv1 WHERE f_leak(b);
 
+-- COPY FROM is not supported for views, so no COPY FROM tests are needed here.
 INSERT INTO bv1 VALUES (-1, 'xxx'); -- should fail view WCO
 INSERT INTO bv1 VALUES (11, 'xxx'); -- should fail RLS check
 INSERT INTO bv1 VALUES (12, 'xxx'); -- ok
@@ -1888,7 +1943,11 @@ SET row_security TO OFF;
 COPY copy_t FROM STDIN; --fail - would be affected by RLS.
 \.
 SET row_security TO ON;
-COPY copy_t FROM STDIN; --fail - COPY FROM not supported by RLS.
+COPY copy_t FROM STDIN WITH (DELIMITER ','); -- ok
+2,abc
+\.
+COPY copy_t FROM STDIN WITH (DELIMITER ','); -- error
+1,abc
 \.
 
 -- Check COPY FROM as user with permissions and BYPASSRLS
@@ -1914,6 +1973,28 @@ RESET SESSION AUTHORIZATION;
 DROP TABLE copy_t;
 DROP TABLE copy_rel_to CASCADE;
 
+-- COPY FROM with multiple policies
+RESET SESSION AUTHORIZATION;
+CREATE TABLE copy_t (a integer, b text);
+CREATE POLICY p1 ON copy_t USING (a % 2 = 0) WITH CHECK (a > 8);
+CREATE POLICY p2 ON copy_t WITH CHECK (a > 10);
+ALTER TABLE copy_t ENABLE ROW LEVEL SECURITY;
+SET row_security TO ON;
+GRANT ALL ON copy_t TO regress_rls_bob, regress_rls_exempt_user;
+SET SESSION AUTHORIZATION regress_rls_bob;
+
+COPY copy_t FROM STDIN WITH (DELIMITER ','); -- fail
+6,abc
+\.
+COPY copy_t FROM STDIN WITH (DELIMITER ',', ON_ERROR IGNORE); -- ok
+14,abc
+13,abc
+a,abc
+\.
+RESET SESSION AUTHORIZATION;
+DROP TABLE copy_t;
+
+
 -- Check WHERE CURRENT OF
 SET SESSION AUTHORIZATION regress_rls_alice;
 
@@ -2092,6 +2173,9 @@ SELECT * FROM r2;
 
 -- r2 is read-only
 INSERT INTO r2 VALUES (2); -- Not allowed
+COPY r2 FROM STDIN WITH (DELIMITER ','); -- fail
+2
+\.
 UPDATE r2 SET a = 2 RETURNING *; -- Updates nothing
 DELETE FROM r2 RETURNING *; -- Deletes nothing
 
@@ -2123,6 +2207,10 @@ TABLE r1;
 
 -- RLS error
 INSERT INTO r1 VALUES (1);
+COPY r1 FROM STDIN WITH (DELIMITER ','); -- fail
+1
+\.
+
 
 -- No error (unable to see any rows to update)
 UPDATE r1 SET a = 1;
@@ -2249,6 +2337,9 @@ ALTER TABLE r1 FORCE ROW LEVEL SECURITY;
 
 -- Works fine
 INSERT INTO r1 VALUES (10), (20);
+COPY r1 FROM STDIN; -- ok
+10
+\.
 
 -- No error, but no rows
 TABLE r1;
@@ -2326,9 +2417,15 @@ ALTER TABLE r1 FORCE ROW LEVEL SECURITY;
 
 -- Should fail p1
 INSERT INTO r1 VALUES (0);
+COPY r1 FROM STDIN WITH (DELIMITER ','); -- fail
+0
+\.
 
 -- Should fail p2
 INSERT INTO r1 VALUES (4);
+COPY r1 FROM STDIN WITH (DELIMITER ','); -- fail
+4
+\.
 
 -- OK
 INSERT INTO r1 VALUES (3);
-- 
2.34.1

