Thread: Privileges on PUBLICATION

Privileges on PUBLICATION

From
Antonin Houska
Date:
Now that the user can specify rows and columns to be omitted from the logical
replication [1], I suppose hiding rows and columns from the subscriber is an
important use case. However, since the subscription connection user (i.e. the
user specified in the CREATE SUBSCRIPTION ... CONNECTION ... command) needs
SELECT permission on the replicated table (on the publication side), he can
just use another publication (which has different filters or no filters at
all) to get the supposedly-hidden data replicated.

Don't we need privileges on publication (e.g GRANT USAGE ON PUBLICATION ...)
now?

[1] https://www.postgresql.org/docs/devel/sql-createpublication.html

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com



Re: Privileges on PUBLICATION

From
"Euler Taveira"
Date:
On Mon, May 9, 2022, at 11:09 AM, Antonin Houska wrote:
Now that the user can specify rows and columns to be omitted from the logical
replication [1], I suppose hiding rows and columns from the subscriber is an
important use case. However, since the subscription connection user (i.e. the
user specified in the CREATE SUBSCRIPTION ... CONNECTION ... command) needs
SELECT permission on the replicated table (on the publication side), he can
just use another publication (which has different filters or no filters at
all) to get the supposedly-hidden data replicated.
The required privileges were not relaxed on publisher after the row filter and 
column list features. It is not just to "create another publication". Create
publications require CREATE privilege on databases (that is *not* granted to
PUBLIC).If you have an untrusted user that could bypass your rules about hidden
data, it is better to review your user privileges.

postgres=# CREATE ROLE foo REPLICATION LOGIN;
CREATE ROLE
postgres=# \c - foo
You are now connected to database "postgres" as user "foo".
postgres=> CREATE PUBLICATION pub1;
ERROR:  permission denied for database postgres

The documentation [1] says

"The role used for the replication connection must have the REPLICATION
attribute (or be a superuser)."

You can use role foo for the replication connection but role foo couldn't be a
superuser. In this case, even if role foo open a connection to database
postgres, a publication cannot be created due to lack of privileges.

Don't we need privileges on publication (e.g GRANT USAGE ON PUBLICATION ...)
now?
Maybe. We rely on CREATE privilege on databases right now. If you say that
GRANT USAGE ON PUBLICATION is just a command that will have the same effect as
REPLICATION property [1] has right now, I would say it won't. Are you aiming a
fine-grained access control on publisher?




--
Euler Taveira

Re: Privileges on PUBLICATION

From
Amit Kapila
Date:
On Tue, May 10, 2022 at 12:16 AM Euler Taveira <euler@eulerto.com> wrote:
>
> On Mon, May 9, 2022, at 11:09 AM, Antonin Houska wrote:
>
> Now that the user can specify rows and columns to be omitted from the logical
> replication [1], I suppose hiding rows and columns from the subscriber is an
> important use case. However, since the subscription connection user (i.e. the
> user specified in the CREATE SUBSCRIPTION ... CONNECTION ... command) needs
> SELECT permission on the replicated table (on the publication side), he can
> just use another publication (which has different filters or no filters at
> all) to get the supposedly-hidden data replicated.
>
> The required privileges were not relaxed on publisher after the row filter and
> column list features. It is not just to "create another publication". Create
> publications require CREATE privilege on databases (that is *not* granted to
> PUBLIC).If you have an untrusted user that could bypass your rules about hidden
> data, it is better to review your user privileges.
>

Also, to create a subscription (which combines multiple publications
to bypass rules), a user must be a superuser. So, isn't that a
sufficient guarantee that users shouldn't be able to bypass such
rules?

-- 
With Regards,
Amit Kapila.



Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Euler Taveira <euler@eulerto.com> wrote:

> On Mon, May 9, 2022, at 11:09 AM, Antonin Houska wrote:
>
>  Now that the user can specify rows and columns to be omitted from the logical
>  replication [1], I suppose hiding rows and columns from the subscriber is an
>  important use case. However, since the subscription connection user (i.e. the
>  user specified in the CREATE SUBSCRIPTION ... CONNECTION ... command) needs
>  SELECT permission on the replicated table (on the publication side), he can
>  just use another publication (which has different filters or no filters at
>  all) to get the supposedly-hidden data replicated.
>
> The required privileges were not relaxed on publisher after the row filter and
> column list features. It is not just to "create another publication". Create
> publications require CREATE privilege on databases (that is *not* granted to
> PUBLIC).If you have an untrusted user that could bypass your rules about hidden
> data, it is better to review your user privileges.
>
> postgres=# CREATE ROLE foo REPLICATION LOGIN;
> CREATE ROLE
> postgres=# \c - foo
> You are now connected to database "postgres" as user "foo".
> postgres=> CREATE PUBLICATION pub1;
> ERROR:  permission denied for database postgres
>
> The documentation [1] says
>
> "The role used for the replication connection must have the REPLICATION
> attribute (or be a superuser)."
>
> You can use role foo for the replication connection but role foo couldn't be a
> superuser. In this case, even if role foo open a connection to database
> postgres, a publication cannot be created due to lack of privileges.
>
>  Don't we need privileges on publication (e.g GRANT USAGE ON PUBLICATION ...)
>  now?
>
> Maybe. We rely on CREATE privilege on databases right now. If you say that
> GRANT USAGE ON PUBLICATION is just a command that will have the same effect as
> REPLICATION property [1] has right now, I would say it won't. Are you aiming a
> fine-grained access control on publisher?

The configuration I'm thinking of is multiple replicas reading data from the
same master.

For example, consider "foo" and "bar" roles, used by "subscr_foo" and
"subscr_bar" subscriptions respectively. (Therefore, both roles need the
REPLICATION option.) The subscriptions "subscr_foo" and "subscr_bar" are
located in "db_foo" and "db_bar" databases respectively.

On the master side, there are two publications: "pub_foo" and "pub_bar", to be
used by "subscr_foo" and "subscr_bar" subscriptions respectively. The
publications replicate the same table, but each with a different row filter.

The problem is that the admin of "db_foo" can add the "pub_bar" publication to
the "subscr_foo" subscription, and thus get the data that his "pub_foo" would
filter out. Likewise, the admin of "db_bar" can "steal" the data from
"pub_foo" by adding that publication to "subscr_bar".

In this case, the existing publications are misused, so the CREATE PUBLICATION
privileges do not help. Since the REPLICATION option of a role is
cluster-wide, but I need specific roles to be restricted to specific
publications, it can actually be called fine-grained access control as you
say.


> [1] https://www.postgresql.org/docs/devel/logical-replication-security.html

--
Antonin Houska
Web: https://www.cybertec-postgresql.com



Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Amit Kapila <amit.kapila16@gmail.com> wrote:

> On Tue, May 10, 2022 at 12:16 AM Euler Taveira <euler@eulerto.com> wrote:
> >
> > On Mon, May 9, 2022, at 11:09 AM, Antonin Houska wrote:
> >
> > Now that the user can specify rows and columns to be omitted from the logical
> > replication [1], I suppose hiding rows and columns from the subscriber is an
> > important use case. However, since the subscription connection user (i.e. the
> > user specified in the CREATE SUBSCRIPTION ... CONNECTION ... command) needs
> > SELECT permission on the replicated table (on the publication side), he can
> > just use another publication (which has different filters or no filters at
> > all) to get the supposedly-hidden data replicated.
> >
> > The required privileges were not relaxed on publisher after the row filter and
> > column list features. It is not just to "create another publication". Create
> > publications require CREATE privilege on databases (that is *not* granted to
> > PUBLIC).If you have an untrusted user that could bypass your rules about hidden
> > data, it is better to review your user privileges.
> >
>
> Also, to create a subscription (which combines multiple publications
> to bypass rules), a user must be a superuser. So, isn't that a
> sufficient guarantee that users shouldn't be able to bypass such
> rules?

My understanding is that the rows/columns filtering is a way for the
*publisher* to control which data is available to particular replica. From
this point of view, the publication privileges would just make the control
complete.

--
Antonin Houska
Web: https://www.cybertec-postgresql.com



Re: Privileges on PUBLICATION

From
Peter Eisentraut
Date:
On 10.05.22 10:37, Antonin Houska wrote:
> My understanding is that the rows/columns filtering is a way for the
> *publisher*  to control which data is available to particular replica. From
> this point of view, the publication privileges would just make the control
> complete.

I think privileges on publications would eventually be useful.  But are 
you arguing that we need them for PG15 to make the new features usable 
safely?




Re: Privileges on PUBLICATION

From
"Euler Taveira"
Date:
On Tue, May 10, 2022, at 5:37 AM, Antonin Houska wrote:
My understanding is that the rows/columns filtering is a way for the
*publisher* to control which data is available to particular replica. From
this point of view, the publication privileges would just make the control
complete.
I agree. IMO it is a new feature. We already require high privilege for logical
replication. Hence, we expect the replication user to have access to all data.
Unfortunately, nobody mentioned about this requirement during the row filter /
column list development; someone could have written a patch for GRANT ... ON
PUBLICATION.

I understand your concern. Like I said in my last sentence in the previous
email: it is a fine-grained access control on the publisher. Keep in mind that
it will *only* work for non-superusers (REPLICATION attribute). It is not
exposing something that we didn't expose before. In this particular case, there
is no mechanism to prevent the subscriber to obtain data provided by the
various row filters if they know the publication names. We could probably add a
sentence to "Logical Replication > Security" section:

There is no privileges for publications. If you have multiple publications in a
database, a subscription can use all publications available.


--
Euler Taveira

Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Euler Taveira <euler@eulerto.com> wrote:

> On Tue, May 10, 2022, at 5:37 AM, Antonin Houska wrote:
> 
>  My understanding is that the rows/columns filtering is a way for the
>  *publisher* to control which data is available to particular replica. From
>  this point of view, the publication privileges would just make the control
>  complete.
> 
> I agree. IMO it is a new feature. We already require high privilege for logical
> replication. Hence, we expect the replication user to have access to all data.
> Unfortunately, nobody mentioned about this requirement during the row filter /
> column list development; someone could have written a patch for GRANT ... ON
> PUBLICATION.

I can try that for PG 16, unless someone is already working on it.

> I understand your concern. Like I said in my last sentence in the previous
> email: it is a fine-grained access control on the publisher. Keep in mind that
> it will *only* work for non-superusers (REPLICATION attribute). It is not
> exposing something that we didn't expose before. In this particular case, there
> is no mechanism to prevent the subscriber to obtain data provided by the
> various row filters if they know the publication names. We could probably add a
> sentence to "Logical Replication > Security" section:
> 
> There is no privileges for publications. If you have multiple publications in a
> database, a subscription can use all publications available.

Attached is my proposal. It tries to be more specific and does not mention the
absence of the privileges explicitly.

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com

diff --git a/doc/src/sgml/ref/create_publication.sgml b/doc/src/sgml/ref/create_publication.sgml
index 1a828e8d2ff..b74ba625649 100644
--- a/doc/src/sgml/ref/create_publication.sgml
+++ b/doc/src/sgml/ref/create_publication.sgml
@@ -94,6 +94,16 @@ CREATE PUBLICATION <replaceable class="parameter">name</replaceable>
       list is specified, it must include the replica identity columns.
      </para>
 
+     <warning>
+      <para>
+       If you are using the <literal>WHERE</literal> clause or the column list
+       to omit some table data from the replication for security reasons,
+       please make sure that the same data is not exposed via other
+       publications which contain the same table and have different (or
+       none) <literal>WHERE</literal> clause or column list.
+     </para>
+     </warning>
+
      <para>
       Only persistent base tables and partitioned tables can be part of a
       publication.  Temporary tables, unlogged tables, foreign tables,

Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:

> On 10.05.22 10:37, Antonin Houska wrote:
> > My understanding is that the rows/columns filtering is a way for the
> > *publisher*  to control which data is available to particular replica. From
> > this point of view, the publication privileges would just make the control
> > complete.
>
> I think privileges on publications would eventually be useful.  But are you
> arguing that we need them for PG15 to make the new features usable safely?

I didn't think that far, but user should be aware of the problem. My proposal
of documentation is in https://www.postgresql.org/message-id/5859.1652423797%40antos

--
Antonin Houska
Web: https://www.cybertec-postgresql.com



Re: Privileges on PUBLICATION

From
"Euler Taveira"
Date:
On Fri, May 13, 2022, at 3:36 AM, Antonin Houska wrote:
Attached is my proposal. It tries to be more specific and does not mention the
absence of the privileges explicitly.
You explained the current issue but say nothing about the limitation. This
information will trigger a question possibly in one of the MLs. IMO if you say
something like the sentence above at the end, it will make it clear why that
setup expose all data (there is no access control to publications) and
explicitly say there is a TODO here.

Additional privileges might be added to control access to table data in a
future version of <productname>PostgreSQL</productname>.

I also wouldn't use the warning tag because it fits in the same category as the
other restrictions listed in the page.


--
Euler Taveira

Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Antonin Houska <ah@cybertec.at> wrote:

> Euler Taveira <euler@eulerto.com> wrote:
> 
> > On Tue, May 10, 2022, at 5:37 AM, Antonin Houska wrote:
> > 
> >  My understanding is that the rows/columns filtering is a way for the
> >  *publisher* to control which data is available to particular replica. From
> >  this point of view, the publication privileges would just make the control
> >  complete.
> > 
> > I agree. IMO it is a new feature. We already require high privilege for logical
> > replication. Hence, we expect the replication user to have access to all data.
> > Unfortunately, nobody mentioned about this requirement during the row filter /
> > column list development; someone could have written a patch for GRANT ... ON
> > PUBLICATION.
> 
> I can try that for PG 16, unless someone is already working on it.

The patch is attached to this message.

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com


Attachment

Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Euler Taveira <euler@eulerto.com> wrote:

> On Fri, May 13, 2022, at 3:36 AM, Antonin Houska wrote:
> 
>  Attached is my proposal. It tries to be more specific and does not mention the
>  absence of the privileges explicitly.
> 
> You explained the current issue but say nothing about the limitation. This
> information will trigger a question possibly in one of the MLs. IMO if you say
> something like the sentence above at the end, it will make it clear why that
> setup expose all data (there is no access control to publications) and
> explicitly say there is a TODO here.
> 
> Additional privileges might be added to control access to table data in a
> future version of <productname>PostgreSQL</productname>.

I thought it sound too negative if absence of some feature was mentioned
explicitly. However it makes sense to be clear from technical point of view.

> I also wouldn't use the warning tag because it fits in the same category as the
> other restrictions listed in the page.

ok, please see the next version.

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com


Attachment

Re: Privileges on PUBLICATION

From
"Euler Taveira"
Date:
On Wed, May 18, 2022, at 6:16 AM, Antonin Houska wrote:
The patch is attached to this message.
Great. Add it to the next CF. I'll review it when I have some spare time.


--
Euler Taveira

Re: Privileges on PUBLICATION

From
"Euler Taveira"
Date:
On Wed, May 18, 2022, at 6:44 AM, Antonin Houska wrote:
ok, please see the next version.
The new paragraph looks good to me. I'm not sure if the CREATE PUBLICATION is
the right place to provide such information. As I suggested in a previous email
[1], you could add it to "Logical Replication > Security".



--
Euler Taveira

Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Euler Taveira <euler@eulerto.com> wrote:

> On Wed, May 18, 2022, at 6:16 AM, Antonin Houska wrote:
> 
>  The patch is attached to this message.
> 
> Great. Add it to the next CF. I'll review it when I have some spare time.

https://commitfest.postgresql.org/38/3641/

Thanks!

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com



Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Euler Taveira <euler@eulerto.com> wrote:

> --eeab359ad6094efd84562cddd7fb9e89
> Content-Type: text/plain
> 
> On Wed, May 18, 2022, at 6:44 AM, Antonin Houska wrote:
> > ok, please see the next version.
> The new paragraph looks good to me. I'm not sure if the CREATE PUBLICATION is
> the right place to provide such information. As I suggested in a previous email
> [1], you could add it to "Logical Replication > Security".

ok, I missed that. The next version moves the text there.

> [1] https://postgr.es/m/d96103fe-99e2-4119-bd76-952d326b7539@www.fastmail.com

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com


Attachment

Re: Privileges on PUBLICATION

From
Peter Eisentraut
Date:
On 20.06.22 16:01, Antonin Houska wrote:
>> On Wed, May 18, 2022, at 6:44 AM, Antonin Houska wrote:
>>> ok, please see the next version.
>> The new paragraph looks good to me. I'm not sure if the CREATE PUBLICATION is
>> the right place to provide such information. As I suggested in a previous email
>> [1], you could add it to "Logical Replication > Security".
> 
> ok, I missed that. The next version moves the text there.

I have committed this patch that adds the additional documentation.

The CF entry is about privileges on publications.  Please rebase that 
patch and repost it so that the CF app and the CF bot are up to date.



Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:

> The CF entry is about privileges on publications.  Please rebase that patch
> and repost it so that the CF app and the CF bot are up to date.

The rebased patch (with regression tests added) is attached here.

There's still one design issue that I haven't mentioned yet: if the USAGE
privilege on a publication is revoked after the synchronization phase
completed, the missing privilege on a publication causes ERROR in the output
plugin. If the privilege is then granted, the error does not disappear because
the same (historical) snapshot we use to decode the failed data change again
is also used to check the privileges in the catalog, so the output plugin does
not see that the privilege has already been granted.

The only solution seems to be to drop the publication from the subscription
and add it again, or to drop and re-create the whole subscription. I haven't
added a note about this problem to the documentation yet, in case someone has
better idea how to approach the problem.

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com

diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index 03c0193709..d510220a07 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -1935,6 +1935,13 @@ REVOKE ALL ON accounts FROM PUBLIC;
        statements that have previously performed this lookup, so this is not
        a completely secure way to prevent object access.
       </para>
+      <para>
+       For publications, allows logical replication via particular
+       publication. The user specified in
+       the <link linkend="sql-createsubscription"><command>CREATE
+       SUBSCRIPTION</command></link> command must have this privilege on all
+       publications listed in that command.
+      </para>
       <para>
        For sequences, allows use of the
        <function>currval</function> and <function>nextval</function> functions.
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index f8756389a3..4286d709d9 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -1712,7 +1712,9 @@ CONTEXT:  processing remote data for replication origin "pg_16395" during "INSER
   <para>
    In order to be able to copy the initial table data, the role used for the
    replication connection must have the <literal>SELECT</literal> privilege on
-   a published table (or be a superuser).
+   a published table (or be a superuser). In addition, the role must have
+   the <literal>USAGE</literal> privilege on all the publications referenced
+   by particular subscription.
   </para>
 
   <para>
@@ -1728,14 +1730,12 @@ CONTEXT:  processing remote data for replication origin "pg_16395" during "INSER
   </para>
 
   <para>
-   There are currently no privileges on publications.  Any subscription (that
-   is able to connect) can access any publication.  Thus, if you intend to
-   hide some information from particular subscribers, such as by using row
-   filters or column lists, or by not adding the whole table to the
-   publication, be aware that other publications in the same database could
-   expose the same information.  Publication privileges might be added to
-   <productname>PostgreSQL</productname> in the future to allow for
-   finer-grained access control.
+   If you intend to hide some information from particular subscribers, such as
+   by using row filters or column lists, or by not adding the whole table to
+   the publication, be aware that other publications in the same database
+   could expose the same
+   information. <link linkend="ddl-priv">Privileges</link> on publication can
+   be used to implement finer-grained access control.
   </para>
 
   <para>
diff --git a/doc/src/sgml/ref/grant.sgml b/doc/src/sgml/ref/grant.sgml
index dea19cd348..e62b7f643c 100644
--- a/doc/src/sgml/ref/grant.sgml
+++ b/doc/src/sgml/ref/grant.sgml
@@ -82,6 +82,11 @@ GRANT { { SET | ALTER SYSTEM } [, ... ] | ALL [ PRIVILEGES ] }
     TO <replaceable class="parameter">role_specification</replaceable> [, ...] [ WITH GRANT OPTION ]
     [ GRANTED BY <replaceable class="parameter">role_specification</replaceable> ]
 
+GRANT { USAGE | ALL [ PRIVILEGES ] }
+    ON PUBLICATION <replaceable>pub_name</replaceable> [, ...]
+    TO <replaceable class="parameter">role_specification</replaceable> [, ...] [ WITH GRANT OPTION ]
+    [ GRANTED BY <replaceable class="parameter">role_specification</replaceable> ]
+
 GRANT { { CREATE | USAGE } [, ...] | ALL [ PRIVILEGES ] }
     ON SCHEMA <replaceable>schema_name</replaceable> [, ...]
     TO <replaceable class="parameter">role_specification</replaceable> [, ...] [ WITH GRANT OPTION ]
@@ -488,7 +493,7 @@ GRANT admins TO joe;
    </para>
 
    <para>
-    Privileges on databases, tablespaces, schemas, languages, and
+    Privileges on databases, tablespaces, schemas, languages, publications and
     configuration parameters are
     <productname>PostgreSQL</productname> extensions.
    </para>
diff --git a/doc/src/sgml/ref/revoke.sgml b/doc/src/sgml/ref/revoke.sgml
index 4fd4bfb3d7..7e8d018743 100644
--- a/doc/src/sgml/ref/revoke.sgml
+++ b/doc/src/sgml/ref/revoke.sgml
@@ -104,6 +104,13 @@ REVOKE [ GRANT OPTION FOR ]
     [ GRANTED BY <replaceable class="parameter">role_specification</replaceable> ]
     [ CASCADE | RESTRICT ]
 
+REVOKE [ GRANT OPTION FOR ]
+    { USAGE | ALL [ PRIVILEGES ] }
+    ON PUBLICATION <replaceable>pub_name</replaceable> [, ...]
+    FROM <replaceable class="parameter">role_specification</replaceable> [, ...]
+    [ GRANTED BY <replaceable class="parameter">role_specification</replaceable> ]
+    [ CASCADE | RESTRICT ]
+
 REVOKE [ GRANT OPTION FOR ]
     { { CREATE | USAGE } [, ...] | ALL [ PRIVILEGES ] }
     ON SCHEMA <replaceable>schema_name</replaceable> [, ...]
diff --git a/src/backend/catalog/aclchk.c b/src/backend/catalog/aclchk.c
index aa5a2ed948..42eff8d8a4 100644
--- a/src/backend/catalog/aclchk.c
+++ b/src/backend/catalog/aclchk.c
@@ -111,6 +111,7 @@ static void ExecGrant_ForeignServer(InternalGrant *istmt);
 static void ExecGrant_Function(InternalGrant *istmt);
 static void ExecGrant_Language(InternalGrant *istmt);
 static void ExecGrant_Largeobject(InternalGrant *istmt);
+static void ExecGrant_Publication(InternalGrant *istmt);
 static void ExecGrant_Namespace(InternalGrant *istmt);
 static void ExecGrant_Tablespace(InternalGrant *istmt);
 static void ExecGrant_Type(InternalGrant *istmt);
@@ -478,6 +479,10 @@ ExecuteGrantStmt(GrantStmt *stmt)
             all_privileges = ACL_ALL_RIGHTS_LARGEOBJECT;
             errormsg = gettext_noop("invalid privilege type %s for large object");
             break;
+        case OBJECT_PUBLICATION:
+            all_privileges = ACL_ALL_RIGHTS_PUBLICATION;
+            errormsg = gettext_noop("invalid privilege type %s for publication");
+            break;
         case OBJECT_SCHEMA:
             all_privileges = ACL_ALL_RIGHTS_SCHEMA;
             errormsg = gettext_noop("invalid privilege type %s for schema");
@@ -606,6 +611,9 @@ ExecGrantStmt_oids(InternalGrant *istmt)
         case OBJECT_LARGEOBJECT:
             ExecGrant_Largeobject(istmt);
             break;
+        case OBJECT_PUBLICATION:
+            ExecGrant_Publication(istmt);
+            break;
         case OBJECT_SCHEMA:
             ExecGrant_Namespace(istmt);
             break;
@@ -716,6 +724,16 @@ objectNamesToOids(ObjectType objtype, List *objnames, bool is_grant)
                 objects = lappend_oid(objects, lobjOid);
             }
             break;
+        case OBJECT_PUBLICATION:
+            foreach(cell, objnames)
+            {
+                char       *nspname = strVal(lfirst(cell));
+                Oid            oid;
+
+                oid = get_publication_oid(nspname, false);
+                objects = lappend_oid(objects, oid);
+            }
+            break;
         case OBJECT_SCHEMA:
             foreach(cell, objnames)
             {
@@ -2854,6 +2872,126 @@ ExecGrant_Largeobject(InternalGrant *istmt)
     table_close(relation, RowExclusiveLock);
 }
 
+static void
+ExecGrant_Publication(InternalGrant *istmt)
+{
+    Relation    relation;
+    ListCell   *cell;
+
+    if (istmt->all_privs && istmt->privileges == ACL_NO_RIGHTS)
+        istmt->privileges = ACL_ALL_RIGHTS_PUBLICATION;
+
+    relation = table_open(PublicationRelationId, RowExclusiveLock);
+
+    foreach(cell, istmt->objects)
+    {
+        Oid            pubid = lfirst_oid(cell);
+        HeapTuple    tuple;
+        HeapTuple    newtuple;
+        Form_pg_publication pg_pub_tuple;
+        Oid            ownerId;
+        Datum        aclDatum;
+        bool        isnull;
+        AclMode        avail_goptions;
+        AclMode        this_privileges;
+        Acl           *old_acl;
+        Acl           *new_acl;
+        Oid            grantorId;
+        Datum        values[Natts_pg_publication];
+        bool        nulls[Natts_pg_publication];
+        bool        replaces[Natts_pg_publication];
+        int            noldmembers;
+        int            nnewmembers;
+        Oid           *oldmembers;
+        Oid           *newmembers;
+
+        tuple = SearchSysCache1(PUBLICATIONOID, ObjectIdGetDatum(pubid));
+        if (!HeapTupleIsValid(tuple))
+            elog(ERROR, "cache lookup failed for publication %u", pubid);
+
+        pg_pub_tuple = (Form_pg_publication) GETSTRUCT(tuple);
+        ownerId = pg_pub_tuple->pubowner;
+        aclDatum = SysCacheGetAttr(PUBLICATIONOID, tuple,
+                                   Anum_pg_publication_pubacl,
+                                   &isnull);
+
+        if (isnull)
+        {
+            old_acl = acldefault(OBJECT_PUBLICATION, ownerId);
+            /* There are no old member roles according to the catalogs */
+            noldmembers = 0;
+            oldmembers = NULL;
+        }
+        else
+        {
+            old_acl = DatumGetAclPCopy(aclDatum);
+            /* Get the roles mentioned in the existing ACL */
+            noldmembers = aclmembers(old_acl, &oldmembers);
+        }
+
+        /* Determine ID to do the grant as, and available grant options */
+        select_best_grantor(GetUserId(), istmt->privileges,
+                            old_acl, ownerId,
+                            &grantorId, &avail_goptions);
+
+        /*
+         * Restrict the privileges to what we can actually grant, and emit the
+         * standards-mandated warning and error messages.
+         */
+        this_privileges =
+            restrict_and_check_grant(istmt->is_grant, avail_goptions,
+                                     istmt->all_privs, istmt->privileges,
+                                     pubid, grantorId, OBJECT_FUNCTION,
+                                     NameStr(pg_pub_tuple->pubname),
+                                     0, NULL);
+
+        /*
+         * Generate new ACL.
+         */
+        new_acl = merge_acl_with_grant(old_acl, istmt->is_grant,
+                                       istmt->grant_option, istmt->behavior,
+                                       istmt->grantees, this_privileges,
+                                       grantorId, ownerId);
+
+        /*
+         * We need the members of both old and new ACLs so we can correct the
+         * shared dependency information.
+         */
+        nnewmembers = aclmembers(new_acl, &newmembers);
+
+        /* finished building new ACL value, now insert it */
+        MemSet(values, 0, sizeof(values));
+        MemSet(nulls, false, sizeof(nulls));
+        MemSet(replaces, false, sizeof(replaces));
+
+        replaces[Anum_pg_publication_pubacl - 1] = true;
+        values[Anum_pg_publication_pubacl - 1] = PointerGetDatum(new_acl);
+
+        newtuple = heap_modify_tuple(tuple, RelationGetDescr(relation), values,
+                                     nulls, replaces);
+
+        CatalogTupleUpdate(relation, &newtuple->t_self, newtuple);
+
+        /* Update initial privileges for extensions */
+        recordExtensionInitPriv(pubid, PublicationRelationId, 0, new_acl);
+
+        /* Update the shared dependency ACL info */
+        updateAclDependencies(PublicationRelationId, pubid, 0,
+                              ownerId,
+                              noldmembers, oldmembers,
+                              nnewmembers, newmembers);
+
+        ReleaseSysCache(tuple);
+
+        pfree(new_acl);
+
+        /* prevent error when processing duplicate objects */
+        CommandCounterIncrement();
+    }
+
+    table_close(relation, RowExclusiveLock);
+}
+
 static void
 ExecGrant_Namespace(InternalGrant *istmt)
 {
@@ -3802,6 +3940,8 @@ pg_aclmask(ObjectType objtype, Oid table_oid, AttrNumber attnum, Oid roleid,
             return pg_database_aclmask(table_oid, roleid, mask, how);
         case OBJECT_FUNCTION:
             return pg_proc_aclmask(table_oid, roleid, mask, how);
+        case OBJECT_PUBLICATION:
+            return pg_publication_aclmask(table_oid, roleid, mask, how);
         case OBJECT_LANGUAGE:
             return pg_language_aclmask(table_oid, roleid, mask, how);
         case OBJECT_LARGEOBJECT:
@@ -4325,6 +4465,60 @@ pg_proc_aclmask(Oid proc_oid, Oid roleid,
     return result;
 }
 
+/*
+ * Exported routine for examining a user's privileges for a publication.
+ */
+AclMode
+pg_publication_aclmask(Oid pubid, Oid roleid, AclMode mask,
+                       AclMaskHow how)
+{
+    AclMode        result;
+    HeapTuple    tuple;
+    Datum        aclDatum;
+    bool        isNull;
+    Acl           *acl;
+    Oid            ownerId;
+
+    /* Superusers bypass all permission checking. */
+    if (superuser_arg(roleid))
+        return mask;
+
+    /*
+     * Get the publication's ACL from pg_publication
+     */
+    tuple = SearchSysCache1(PUBLICATIONOID, ObjectIdGetDatum(pubid));
+    if (!HeapTupleIsValid(tuple))
+        ereport(ERROR,
+                (errcode(ERRCODE_UNDEFINED_FUNCTION),
+                 errmsg("publication with OID %u does not exist", pubid)));
+
+    ownerId = ((Form_pg_publication) GETSTRUCT(tuple))->pubowner;
+
+    aclDatum = SysCacheGetAttr(PUBLICATIONOID, tuple, Anum_pg_publication_pubacl,
+                               &isNull);
+    if (isNull)
+    {
+        /* No ACL, so build default ACL */
+        acl = acldefault(OBJECT_PUBLICATION, ownerId);
+        aclDatum = (Datum) 0;
+    }
+    else
+    {
+        /* detoast ACL if necessary */
+        acl = DatumGetAclP(aclDatum);
+    }
+
+    result = aclmask(acl, roleid, ownerId, mask, how);
+
+    /* if we have a detoasted copy, free it */
+    if (acl && (Pointer) acl != DatumGetPointer(aclDatum))
+        pfree(acl);
+
+    ReleaseSysCache(tuple);
+
+    return result;
+}
+
 /*
  * Exported routine for examining a user's privileges for a language
  */
@@ -5022,6 +5216,18 @@ pg_proc_aclcheck(Oid proc_oid, Oid roleid, AclMode mode)
         return ACLCHECK_NO_PRIV;
 }
 
+/*
+ * Exported routine for checking a user's access privileges to a publication
+ */
+AclResult
+pg_publication_aclcheck(Oid pub_oid, Oid roleid, AclMode mode)
+{
+    if (pg_publication_aclmask(pub_oid, roleid, mode, ACLMASK_ANY) != 0)
+        return ACLCHECK_OK;
+    else
+        return ACLCHECK_NO_PRIV;
+}
+
 /*
  * Exported routine for checking a user's access privileges to a language
  */
diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c
index f26cc0d162..e69ee2c82a 100644
--- a/src/backend/commands/copyto.c
+++ b/src/backend/commands/copyto.c
@@ -27,6 +27,7 @@
 #include "commands/progress.h"
 #include "executor/execdesc.h"
 #include "executor/executor.h"
+#include "executor/spi.h"
 #include "executor/tuptable.h"
 #include "libpq/libpq.h"
 #include "libpq/pqformat.h"
@@ -34,9 +35,11 @@
 #include "miscadmin.h"
 #include "optimizer/optimizer.h"
 #include "pgstat.h"
+#include "replication/walsender.h"
 #include "rewrite/rewriteHandler.h"
 #include "storage/fd.h"
 #include "tcop/tcopprot.h"
+#include "utils/acl.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/partcache.h"
@@ -131,6 +134,7 @@ static void CopySendEndOfRow(CopyToState cstate);
 static void CopySendInt32(CopyToState cstate, int32 val);
 static void CopySendInt16(CopyToState cstate, int16 val);
 
+static void check_publication_privileges(List *relids);
 
 /*
  * Send copy start/stop messages for frontend copies.  These have changed
@@ -377,6 +381,7 @@ BeginCopyTo(ParseState *pstate,
         PROGRESS_COPY_COMMAND_TO,
         0
     };
+    List    *pubrelids = NIL;
 
     if (rel != NULL && rel->rd_rel->relkind != RELKIND_RELATION)
     {
@@ -441,6 +446,13 @@ BeginCopyTo(ParseState *pstate,
         cstate->rel = rel;
 
         tupDesc = RelationGetDescr(cstate->rel);
+
+        /*
+         * In walsender, we need to check the privileges of the related
+         * publications.
+         */
+        if (am_walsender)
+            pubrelids = lappend_oid(pubrelids, RelationGetRelid(rel));
     }
     else
     {
@@ -570,9 +582,32 @@ BeginCopyTo(ParseState *pstate,
          */
         ExecutorStart(cstate->queryDesc, 0);
 
+        /*
+         * In walsender, we need to check the privileges of the related
+         * publications.
+         */
+        if (am_walsender)
+        {
+            ListCell    *lc;
+
+            foreach(lc, plan->rtable)
+            {
+                RangeTblEntry    *rte = lfirst_node(RangeTblEntry, lc);
+
+                if (rte->rtekind != RTE_RELATION)
+                    continue;
+
+                pubrelids = lappend_oid(pubrelids, rte->relid);
+            }
+        }
+
         tupDesc = cstate->queryDesc->tupDesc;
     }
 
+    /* Check if publication privileges allow reading from the relations. */
+    if (pubrelids)
+        check_publication_privileges(pubrelids);
+
     /* Generate or convert list of attributes to process */
     cstate->attnumlist = CopyGetAttnums(tupDesc, cstate->rel, attnamelist);
 
@@ -1287,3 +1322,82 @@ CreateCopyDestReceiver(void)
 
     return (DestReceiver *) self;
 }
+
+static void
+check_publication_privileges(List *relids)
+{
+    ListCell    *lc;
+    StringInfoData    bufOids;
+    StringInfoData    bufQuery;
+    int        i;
+    List    *pubids = NIL;
+
+    if (list_length(relids) == 0)
+        return;
+
+    /* Construct a list of the OIDs */
+    initStringInfo(&bufOids);
+    foreach(lc, relids)
+    {
+        Oid        oid = lfirst_oid(lc);
+
+        if (bufOids.len > 0)
+            appendStringInfoString(&bufOids, ", ");
+        appendStringInfo(&bufOids, "%u", oid);
+    }
+
+    /*
+     * Construct the query to retrieve the publication names. (Similar query
+     * is in fetch_remote_table_info()).
+     */
+    initStringInfo(&bufQuery);
+    appendStringInfo(&bufQuery,
+                     "SELECT DISTINCT p.oid"
+                     "  FROM pg_publication p"
+                     "  LEFT OUTER JOIN pg_publication_rel pr"
+                     "       ON p.oid = pr.prpubid, "
+                     "  LATERAL pg_get_publication_tables(p.pubname) gpt"
+                     " WHERE pr.prrelid IN (%s) AND gpt.relid IN (%s)",
+                     bufOids.data, bufOids.data);
+    list_free(relids);
+
+    /* Run the query to get the publication names. */
+    if (SPI_connect() != SPI_OK_CONNECT)
+        elog(ERROR, "SPI_connect failed");
+
+    if (SPI_exec(bufQuery.data, 0) != SPI_OK_SELECT)
+        elog(ERROR, "SPI_exec failed: %s", bufQuery.data);
+
+    for (i = 0; i < SPI_processed; i++)
+    {
+        Datum    oidD;
+        bool    isnull;
+
+        oidD = SPI_getbinval(SPI_tuptable->vals[i],
+                             SPI_tuptable->tupdesc,
+                             1,
+                             &isnull);
+        Assert(!isnull);
+        pubids = lappend_oid(pubids, DatumGetObjectId(oidD));
+    }
+
+    /* Finally, check the privileges. */
+    foreach(lc, pubids)
+    {
+        Oid        oid = lfirst_oid(lc);
+
+        if (pg_publication_aclcheck(oid, GetUserId(), ACL_USAGE) != ACLCHECK_OK)
+        {
+            char    *pubname = get_publication_name(oid, false);
+
+            ereport(ERROR,
+                    (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+                     errmsg("permission denied for publication %s",
+                            pubname)));
+        }
+    }
+    list_free(pubids);
+
+    if (SPI_finish() != SPI_OK_FINISH)
+        elog(ERROR, "SPI_finish failed");
+}
diff --git a/src/backend/commands/publicationcmds.c b/src/backend/commands/publicationcmds.c
index a8b75eb1be..2777874d09 100644
--- a/src/backend/commands/publicationcmds.c
+++ b/src/backend/commands/publicationcmds.c
@@ -799,6 +799,8 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
         BoolGetDatum(pubactions.pubtruncate);
     values[Anum_pg_publication_pubviaroot - 1] =
         BoolGetDatum(publish_via_partition_root);
+    values[Anum_pg_publication_pubowner - 1] = ObjectIdGetDatum(GetUserId());
+    nulls[Anum_pg_publication_pubacl - 1] = true;
 
     tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
 
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 737bd2d06d..7eddd1073a 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -7635,6 +7635,14 @@ privilege_target:
                     n->objs = $2;
                     $$ = n;
                 }
+            | PUBLICATION name_list
+                {
+                    PrivTarget *n = (PrivTarget *) palloc(sizeof(PrivTarget));
+                    n->targtype = ACL_TARGET_OBJECT;
+                    n->objtype = OBJECT_PUBLICATION;
+                    n->objs = $2;
+                    $$ = n;
+                }
             | SCHEMA name_list
                 {
                     PrivTarget *n = (PrivTarget *) palloc(sizeof(PrivTarget));
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 2ecaa5b907..ecfa0aa857 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -20,12 +20,14 @@
 #include "commands/defrem.h"
 #include "executor/executor.h"
 #include "fmgr.h"
+#include "miscadmin.h"
 #include "nodes/makefuncs.h"
 #include "optimizer/optimizer.h"
 #include "replication/logical.h"
 #include "replication/logicalproto.h"
 #include "replication/origin.h"
 #include "replication/pgoutput.h"
+#include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
@@ -2093,8 +2095,15 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
              * We'll use the top-most relid across all publications. Also
              * track the ancestor level for this publication.
              */
-            Oid            pub_relid = relid;
-            int            ancestor_level = 0;
+            Oid    pub_relid = relid;
+            int    ancestor_level = 0;
+
+            /* Check if we have the usage privilege on the publication. */
+            if (pg_publication_aclcheck(pub->oid, GetUserId(), ACL_USAGE) != ACLCHECK_OK)
+                ereport(ERROR,
+                        (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+                         errmsg("permission denied for publication %s",
+                                pub->name)));
 
             /*
              * If this is a FOR ALL TABLES publication, pick the partition
diff --git a/src/backend/utils/adt/acl.c b/src/backend/utils/adt/acl.c
index 4fac402e5b..3e8877936e 100644
--- a/src/backend/utils/adt/acl.c
+++ b/src/backend/utils/adt/acl.c
@@ -806,6 +806,10 @@ acldefault(ObjectType objtype, Oid ownerId)
             world_default = ACL_NO_RIGHTS;
             owner_default = ACL_ALL_RIGHTS_PARAMETER_ACL;
             break;
+        case OBJECT_PUBLICATION:
+            world_default = ACL_USAGE;
+            owner_default = ACL_ALL_RIGHTS_PUBLICATION;
+            break;
         default:
             elog(ERROR, "unrecognized objtype: %d", (int) objtype);
             world_default = ACL_NO_RIGHTS;    /* keep compiler quiet */
@@ -903,6 +907,9 @@ acldefault_sql(PG_FUNCTION_ARGS)
         case 'T':
             objtype = OBJECT_TYPE;
             break;
+        case 'P':
+            objtype = OBJECT_PUBLICATION;
+            break;
         default:
             elog(ERROR, "unrecognized objtype abbreviation: %c", objtypec);
     }
diff --git a/src/bin/pg_dump/dumputils.c b/src/bin/pg_dump/dumputils.c
index 6e501a5413..91f97a1f93 100644
--- a/src/bin/pg_dump/dumputils.c
+++ b/src/bin/pg_dump/dumputils.c
@@ -504,6 +504,8 @@ do { \
         CONVERT_PRIV('r', "SELECT");
         CONVERT_PRIV('w', "UPDATE");
     }
+    else if (strcmp(type, "PUBLICATION") == 0)
+        CONVERT_PRIV('U', "USAGE");
     else
         abort();
 
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index bd9b066e4e..37e8995ac3 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -3898,6 +3898,8 @@ getPublications(Archive *fout, int *numPublications)
     int            i_pubdelete;
     int            i_pubtruncate;
     int            i_pubviaroot;
+    int            i_pubacl;
+    int            i_acldefault;
     int            i,
                 ntups;
 
@@ -3912,27 +3914,32 @@ getPublications(Archive *fout, int *numPublications)
     resetPQExpBuffer(query);
 
     /* Get the publications. */
-    if (fout->remoteVersion >= 130000)
+    if (fout->remoteVersion >= 150000)
         appendPQExpBufferStr(query,
-                             "SELECT p.tableoid, p.oid, p.pubname, "
-                             "p.pubowner, "
-                             "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, p.pubviaroot "
-                             "FROM pg_publication p");
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, p.pubviaroot,
p.pubacl,acldefault('P', p.pubowner) AS acldefault "
 
+                          "FROM pg_publication p");
+    else if (fout->remoteVersion >= 130000)
+        appendPQExpBuffer(query,
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, p.pubviaroot, '{}' AS
pubacl,'{}' AS acldefault "
 
+                          "FROM pg_publication p");
     else if (fout->remoteVersion >= 110000)
         appendPQExpBufferStr(query,
-                             "SELECT p.tableoid, p.oid, p.pubname, "
-                             "p.pubowner, "
-                             "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, false AS
pubviaroot"
 
-                             "FROM pg_publication p");
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, false AS pubviaroot,
'{}'AS pubacl, '{}' AS acldefault "
 
+                          "FROM pg_publication p");
     else
         appendPQExpBufferStr(query,
-                             "SELECT p.tableoid, p.oid, p.pubname, "
-                             "p.pubowner, "
-                             "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, false AS pubtruncate, false AS
pubviaroot"
 
-                             "FROM pg_publication p");
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, false AS pubtruncate, false AS
pubviaroot,'{}' AS pubacl, '{}' AS acldefault "
 
+                          "FROM pg_publication p");
 
     res = ExecuteSqlQuery(fout, query->data, PGRES_TUPLES_OK);
-
     ntups = PQntuples(res);
 
     i_tableoid = PQfnumber(res, "tableoid");
@@ -3945,6 +3952,8 @@ getPublications(Archive *fout, int *numPublications)
     i_pubdelete = PQfnumber(res, "pubdelete");
     i_pubtruncate = PQfnumber(res, "pubtruncate");
     i_pubviaroot = PQfnumber(res, "pubviaroot");
+    i_pubacl = PQfnumber(res, "pubacl");
+    i_acldefault = PQfnumber(res, "acldefault");
 
     pubinfo = pg_malloc(ntups * sizeof(PublicationInfo));
 
@@ -3969,6 +3978,11 @@ getPublications(Archive *fout, int *numPublications)
             (strcmp(PQgetvalue(res, i, i_pubtruncate), "t") == 0);
         pubinfo[i].pubviaroot =
             (strcmp(PQgetvalue(res, i, i_pubviaroot), "t") == 0);
+        pubinfo[i].dacl.acl = pg_strdup(PQgetvalue(res, i, i_pubacl));
+        pubinfo[i].dacl.acldefault = pg_strdup(PQgetvalue(res, i, i_acldefault));
+        pubinfo[i].dacl.privtype = 0;
+        pubinfo[i].dacl.initprivs = NULL;
+        pubinfo[i].dobj.components |= DUMP_COMPONENT_ACL;
 
         /* Decide whether we want to dump it */
         selectDumpableObject(&(pubinfo[i].dobj), fout);
@@ -4072,6 +4086,11 @@ dumpPublication(Archive *fout, const PublicationInfo *pubinfo)
                      NULL, pubinfo->rolname,
                      pubinfo->dobj.catId, 0, pubinfo->dobj.dumpId);
 
+    if (pubinfo->dobj.dump & DUMP_COMPONENT_ACL)
+        dumpACL(fout, pubinfo->dobj.dumpId, InvalidDumpId, "PUBLICATION",
+                pg_strdup(fmtId(pubinfo->dobj.name)), NULL, NULL,
+                pubinfo->rolname, &pubinfo->dacl);
+
     destroyPQExpBuffer(delq);
     destroyPQExpBuffer(query);
     free(qpubname);
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 427f5d45f6..ddf1b1d11a 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -613,6 +613,7 @@ typedef struct _policyInfo
 typedef struct _PublicationInfo
 {
     DumpableObject dobj;
+    DumpableAcl dacl;
     const char *rolname;
     bool        puballtables;
     bool        pubinsert;
diff --git a/src/bin/psql/tab-complete.c b/src/bin/psql/tab-complete.c
index 4c45e4747a..33e03f1d64 100644
--- a/src/bin/psql/tab-complete.c
+++ b/src/bin/psql/tab-complete.c
@@ -3874,6 +3874,7 @@ psql_completion(const char *text, int start, int end)
                                             "LARGE OBJECT",
                                             "PARAMETER",
                                             "PROCEDURE",
+                                            "PUBLICATION",
                                             "ROUTINE",
                                             "SCHEMA",
                                             "SEQUENCE",
@@ -3911,6 +3912,8 @@ psql_completion(const char *text, int start, int end)
             COMPLETE_WITH_QUERY(Query_for_list_of_languages);
         else if (TailMatches("PROCEDURE"))
             COMPLETE_WITH_VERSIONED_SCHEMA_QUERY(Query_for_list_of_procedures);
+        else if (TailMatches("PUBLICATION"))
+            COMPLETE_WITH_VERSIONED_QUERY(Query_for_list_of_publications);
         else if (TailMatches("ROUTINE"))
             COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_routines);
         else if (TailMatches("SCHEMA"))
diff --git a/src/include/catalog/pg_publication.h b/src/include/catalog/pg_publication.h
index ecf5a28e00..89c7ac120f 100644
--- a/src/include/catalog/pg_publication.h
+++ b/src/include/catalog/pg_publication.h
@@ -54,6 +54,12 @@ CATALOG(pg_publication,6104,PublicationRelationId)
 
     /* true if partition changes are published using root schema */
     bool        pubviaroot;
+
+#ifdef CATALOG_VARLEN            /* variable-length fields start here */
+    /* NOTE: These fields are not present in a relcache entry's rd_rel field. */
+    /* access permissions */
+    aclitem        pubacl[1] BKI_DEFAULT(_null_);
+#endif
 } FormData_pg_publication;
 
 /* ----------------
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 7e7ad3f7e4..ea344abdc7 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -88,8 +88,8 @@ typedef uint32 AclMode;            /* a bitmask of privilege bits */
 #define ACL_REFERENCES    (1<<5)
 #define ACL_TRIGGER        (1<<6)
 #define ACL_EXECUTE        (1<<7)    /* for functions */
-#define ACL_USAGE        (1<<8)    /* for languages, namespaces, FDWs, and
-                                 * servers */
+#define ACL_USAGE        (1<<8)    /* for languages, namespaces, FDWs, servers
+                                 * and publications */
 #define ACL_CREATE        (1<<9)    /* for namespaces and databases */
 #define ACL_CREATE_TEMP (1<<10) /* for databases */
 #define ACL_CONNECT        (1<<11) /* for databases */
diff --git a/src/include/utils/acl.h b/src/include/utils/acl.h
index 9a4df3a5da..54b74fa56f 100644
--- a/src/include/utils/acl.h
+++ b/src/include/utils/acl.h
@@ -168,6 +168,7 @@ typedef struct ArrayType Acl;
 #define ACL_ALL_RIGHTS_SCHEMA        (ACL_USAGE|ACL_CREATE)
 #define ACL_ALL_RIGHTS_TABLESPACE    (ACL_CREATE)
 #define ACL_ALL_RIGHTS_TYPE            (ACL_USAGE)
+#define ACL_ALL_RIGHTS_PUBLICATION    (ACL_USAGE)
 
 /* operation codes for pg_*_aclmask */
 typedef enum
@@ -253,6 +254,8 @@ extern AclMode pg_parameter_acl_aclmask(Oid acl_oid, Oid roleid,
                                         AclMode mask, AclMaskHow how);
 extern AclMode pg_proc_aclmask(Oid proc_oid, Oid roleid,
                                AclMode mask, AclMaskHow how);
+extern AclMode pg_publication_aclmask(Oid pubid, Oid roleid,
+                                      AclMode mask, AclMaskHow how);
 extern AclMode pg_language_aclmask(Oid lang_oid, Oid roleid,
                                    AclMode mask, AclMaskHow how);
 extern AclMode pg_largeobject_aclmask_snapshot(Oid lobj_oid, Oid roleid,
@@ -284,6 +287,7 @@ extern AclResult pg_parameter_aclcheck(const char *name, Oid roleid,
 extern AclResult pg_parameter_acl_aclcheck(Oid acl_oid, Oid roleid,
                                            AclMode mode);
 extern AclResult pg_proc_aclcheck(Oid proc_oid, Oid roleid, AclMode mode);
+extern AclResult pg_publication_aclcheck(Oid pub_oid, Oid roleid, AclMode mode);
 extern AclResult pg_language_aclcheck(Oid lang_oid, Oid roleid, AclMode mode);
 extern AclResult pg_largeobject_aclcheck_snapshot(Oid lobj_oid, Oid roleid,
                                                   AclMode mode, Snapshot snapshot);
diff --git a/src/test/regress/expected/misc_sanity.out b/src/test/regress/expected/misc_sanity.out
index a57fd142a9..14a551a871 100644
--- a/src/test/regress/expected/misc_sanity.out
+++ b/src/test/regress/expected/misc_sanity.out
@@ -60,7 +60,8 @@ ORDER BY 1, 2;
  pg_index                | indpred       | pg_node_tree
  pg_largeobject          | data          | bytea
  pg_largeobject_metadata | lomacl        | aclitem[]
-(11 rows)
+ pg_publication          | pubacl        | aclitem[]
+(12 rows)
 
 -- system catalogs without primary keys
 --
diff --git a/src/test/subscription/t/027_nosuperuser.pl b/src/test/subscription/t/027_nosuperuser.pl
index 8614bf0458..43f5ffd20c 100644
--- a/src/test/subscription/t/027_nosuperuser.pl
+++ b/src/test/subscription/t/027_nosuperuser.pl
@@ -7,8 +7,10 @@ use warnings;
 use PostgreSQL::Test::Cluster;
 use Test::More;
 
-my ($node_publisher, $node_subscriber, $publisher_connstr, $result, $offset);
+my ($node_publisher, $node_subscriber, $publisher_connstr, $result, $offset,
+    $offset_pub);
 $offset = 0;
+$offset_pub = 0;
 
 sub publish_insert
 {
@@ -103,7 +105,8 @@ $node_publisher->init(allows_streaming => 'logical');
 $node_subscriber->init;
 $node_publisher->start;
 $node_subscriber->start;
-$publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+# Non-super user, so that we can publication privileges.
+$publisher_connstr = $node_publisher->connstr . ' dbname=postgres user=regress_alice';
 my %remainder_a = (
     publisher  => 0,
     subscriber => 1);
@@ -141,6 +144,8 @@ for my $node ($node_publisher, $node_subscriber)
 }
 $node_publisher->safe_psql(
     'postgres', qq(
+ALTER ROLE regress_alice REPLICATION;
+
 SET SESSION AUTHORIZATION regress_alice;
 
 CREATE PUBLICATION alice
@@ -316,4 +321,46 @@ expect_replication("alice.unpartitioned", 2, 23, 25,
     "nosuperuser nobypassrls table owner can replicate delete into unpartitioned despite rls"
 );
 
+# Test publication permissions.
+#
+# First, make sure that the user specified in the subscription is not able to
+# access the data, then do some changes. (By deleting everything we make the
+# following checks simpler.)
+$node_publisher->safe_psql(
+    'postgres', qq(
+REVOKE USAGE ON PUBLICATION alice FROM PUBLIC;
+REVOKE USAGE ON PUBLICATION alice FROM regress_alice;
+
+DELETE FROM alice.unpartitioned;
+));
+# Missing permission should cause error.
+expect_failure("alice.unpartitioned", 2, 23, 25,
+               qr/ERROR: ( [A-Z0-9]+:)? permission denied for publication alice/msi, 0);
+# Check that the missing privilege makes table synchronization fail too.
+$node_subscriber->safe_psql(
+    'postgres', qq(
+SET SESSION AUTHORIZATION regress_admin;
+DROP SUBSCRIPTION admin_sub;
+TRUNCATE TABLE alice.unpartitioned;
+CREATE SUBSCRIPTION admin_sub CONNECTION '$publisher_connstr' PUBLICATION alice;
+));
+# Note that expect_failure() does not wait for the end of the synchronization,
+# so if there was any data on publisher side and if it found its way to the
+# subscriber, the function might still see an empty table. So we only rely on
+# the function to check the error message.
+expect_failure("alice.unpartitioned", 0, '', '',
+               qr/ERROR: ( [A-Z0-9]+:)? permission denied for publication alice/msi, 0);
+# Restore the privilege on the publication.
+$node_publisher->safe_psql(
+    'postgres', qq(
+GRANT USAGE ON PUBLICATION alice TO regress_alice;
+));
+# Wait for synchronization to complete.
+$node_subscriber->wait_for_subscription_sync;
+# The replication should work again now.
+publish_insert("alice.unpartitioned", 1);
+expect_replication("alice.unpartitioned", 1, 1, 1,
+   "unpartitioned is replicated as soon as regress_alic has permissions on alice publication"
+);
+
 done_testing();

Re: Privileges on PUBLICATION

From
Peter Eisentraut
Date:
On 03.11.22 01:43, Antonin Houska wrote:
> Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:
> 
>> The CF entry is about privileges on publications.  Please rebase that patch
>> and repost it so that the CF app and the CF bot are up to date.
> 
> The rebased patch (with regression tests added) is attached here.

Some preliminary discussion:

What is the upgrade strategy?  I suppose the options are either that 
publications have a default acl that makes them publicly accessible, 
thus preserving the existing behavior by default, or pg_dump would need 
to create additional GRANT statements when upgrading from pre-PG16.  I 
don't see anything like either of these mentioned in the patch.  What is 
your plan?

You might be interested in this patch, which relates to yours: 
https://commitfest.postgresql.org/40/3955/

Looking at your patch, I would also like to find a way to refactor away 
the ExecGrant_Publication() function.  I'll think about that.

I think you should add some tests under src/test/regress/ for the new 
GRANT and REVOKE statements, just to have some basic coverage that it 
works.  sql/publication.sql would be appropriate, I think.




Re: Privileges on PUBLICATION

From
Amit Kapila
Date:
On Thu, Nov 3, 2022 at 11:12 AM Antonin Houska <ah@cybertec.at> wrote:
>
> Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:
>
> > The CF entry is about privileges on publications.  Please rebase that patch
> > and repost it so that the CF app and the CF bot are up to date.
>
> The rebased patch (with regression tests added) is attached here.
>
> There's still one design issue that I haven't mentioned yet: if the USAGE
> privilege on a publication is revoked after the synchronization phase
> completed, the missing privilege on a publication causes ERROR in the output
> plugin. If the privilege is then granted, the error does not disappear because
> the same (historical) snapshot we use to decode the failed data change again
> is also used to check the privileges in the catalog, so the output plugin does
> not see that the privilege has already been granted.
>

We have a similar problem even when publication is dropped/created.
The replication won't be able to proceed.

> The only solution seems to be to drop the publication from the subscription
> and add it again, or to drop and re-create the whole subscription. I haven't
> added a note about this problem to the documentation yet, in case someone has
> better idea how to approach the problem.
>

I think one possibility is that the user advances the slot used in
replication by using pg_replication_slot_advance() at or after the
location where the privilege is granted. Some other ideas have been
discussed in the thread [1], in particular, see email [2] and
discussion after that but we didn't reach any conclusion.

[1] - https://www.postgresql.org/message-id/CAHut%2BPvMbCsL8PAz1Qc6LNoL0Ag0y3YJtPVJ8V0xVXJOPb%2B0xw%40mail.gmail.com
[2] - https://www.postgresql.org/message-id/CAA4eK1JTwOAniPua04o2EcOXfzRa8ANax%3D3bpx4H-8dH7M2p%3DA%40mail.gmail.com

-- 
With Regards,
Amit Kapila.



Re: Privileges on PUBLICATION

From
Amit Kapila
Date:
On Thu, Nov 3, 2022 at 9:19 PM Peter Eisentraut
<peter.eisentraut@enterprisedb.com> wrote:
>
> On 03.11.22 01:43, Antonin Houska wrote:
> > Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:
> >
> >> The CF entry is about privileges on publications.  Please rebase that patch
> >> and repost it so that the CF app and the CF bot are up to date.
> >
> > The rebased patch (with regression tests added) is attached here.
>
> Some preliminary discussion:
>
> What is the upgrade strategy?  I suppose the options are either that
> publications have a default acl that makes them publicly accessible,
> thus preserving the existing behavior by default, or pg_dump would need
> to create additional GRANT statements when upgrading from pre-PG16.
>

I think making them publicly accessible is a better option.

-- 
With Regards,
Amit Kapila.



Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:

> On 03.11.22 01:43, Antonin Houska wrote:
> > Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:
> >
> >> The CF entry is about privileges on publications.  Please rebase that patch
> >> and repost it so that the CF app and the CF bot are up to date.
> > The rebased patch (with regression tests added) is attached here.
>
> Some preliminary discussion:
>
> What is the upgrade strategy?  I suppose the options are either that
> publications have a default acl that makes them publicly accessible,
> thus preserving the existing behavior by default, or pg_dump would need to
> create additional GRANT statements when upgrading from pre-PG16.  I don't see
> anything like either of these mentioned in the patch.  What is your plan?

So far I considered the first

> You might be interested in this patch, which relates to yours:
> https://commitfest.postgresql.org/40/3955/

ok, I'll check.

> Looking at your patch, I would also like to find a way to refactor away the
> ExecGrant_Publication() function.  I'll think about that.
>
> I think you should add some tests under src/test/regress/ for the new GRANT
> and REVOKE statements, just to have some basic coverage that it works.
> sql/publication.sql would be appropriate, I think.

I thought about the whole concept a bit more and I doubt if the PUBLICATION
privilege is the best approach. In particular, the user specified in CREATE
SUBSCRIPTION ... CONNECTION ... (say "subscription user") needs to have SELECT
privilege on the tables replicated. So if the DBA excludes some columns from
the publication's column list and sets the (publication) privileges in such a
way that the user cannot get the column values via other publications, the
user still can connect to the database directly and get values of the excluded
columns.

As an alternative to the publication privileges, I think that the CREATE
SUBSCRIPTION command could grant ACL_SELECT automatically to the subscription
user on the individual columns contained in the publication column list, and
DROP SUBSCRIPTION would revoke that privilege.

Of course a question is what to do if the replication user already has that
privilege on some columns: either the CREATE SUBSCRIPTION command should raise
ERROR, or we should introduce a new privilege (e.g. ACL_SELECT_PUB) for this
purpose, which would effectivelly be ACL_SELECT, but hidden from the users of
GRANT / REVOKE.

If this approach was taken, the USAGE privilege on schema would need to be
granted / revoked in a similar way.

What do you think about that?

--
Antonin Houska
Web: https://www.cybertec-postgresql.com



Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Amit Kapila <amit.kapila16@gmail.com> wrote:

> On Thu, Nov 3, 2022 at 11:12 AM Antonin Houska <ah@cybertec.at> wrote:
> >
> > Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:
> >
> > > The CF entry is about privileges on publications.  Please rebase that patch
> > > and repost it so that the CF app and the CF bot are up to date.
> >
> > The rebased patch (with regression tests added) is attached here.
> >
> > There's still one design issue that I haven't mentioned yet: if the USAGE
> > privilege on a publication is revoked after the synchronization phase
> > completed, the missing privilege on a publication causes ERROR in the output
> > plugin. If the privilege is then granted, the error does not disappear because
> > the same (historical) snapshot we use to decode the failed data change again
> > is also used to check the privileges in the catalog, so the output plugin does
> > not see that the privilege has already been granted.
> >
>
> We have a similar problem even when publication is dropped/created.
> The replication won't be able to proceed.
>
> > The only solution seems to be to drop the publication from the subscription
> > and add it again, or to drop and re-create the whole subscription. I haven't
> > added a note about this problem to the documentation yet, in case someone has
> > better idea how to approach the problem.
> >
>
> I think one possibility is that the user advances the slot used in
> replication by using pg_replication_slot_advance() at or after the
> location where the privilege is granted. Some other ideas have been
> discussed in the thread [1], in particular, see email [2] and
> discussion after that but we didn't reach any conclusion.

Thanks for feedback. Regarding the publications, I'm not sure what the user
should expect if he revokes the permissions in the middle of processing. In
such a situation, he should be aware that some privileged data could already
have been replicated. My preference in such a situation would be to truncate
the table(s) on the subscriber side and to restart the replication, rather
than fix the replication and keep the subscriber's access to the leaked data.

> [1] - https://www.postgresql.org/message-id/CAHut%2BPvMbCsL8PAz1Qc6LNoL0Ag0y3YJtPVJ8V0xVXJOPb%2B0xw%40mail.gmail.com
> [2] - https://www.postgresql.org/message-id/CAA4eK1JTwOAniPua04o2EcOXfzRa8ANax%3D3bpx4H-8dH7M2p%3DA%40mail.gmail.com

--
Antonin Houska
Web: https://www.cybertec-postgresql.com



Re: Privileges on PUBLICATION

From
Mark Dilger
Date:

> On Nov 4, 2022, at 12:28 AM, Antonin Houska <ah@cybertec.at> wrote:
>
> I thought about the whole concept a bit more and I doubt if the PUBLICATION
> privilege is the best approach. In particular, the user specified in CREATE
> SUBSCRIPTION ... CONNECTION ... (say "subscription user") needs to have SELECT
> privilege on the tables replicated. So if the DBA excludes some columns from
> the publication's column list and sets the (publication) privileges in such a
> way that the user cannot get the column values via other publications, the
> user still can connect to the database directly and get values of the excluded
> columns.
>
> As an alternative to the publication privileges, I think that the CREATE
> SUBSCRIPTION command could grant ACL_SELECT automatically to the subscription
> user on the individual columns contained in the publication column list, and
> DROP SUBSCRIPTION would revoke that privilege.
>
> Of course a question is what to do if the replication user already has that
> privilege on some columns: either the CREATE SUBSCRIPTION command should raise
> ERROR, or we should introduce a new privilege (e.g. ACL_SELECT_PUB) for this
> purpose, which would effectivelly be ACL_SELECT, but hidden from the users of
> GRANT / REVOKE.
>
> If this approach was taken, the USAGE privilege on schema would need to be
> granted / revoked in a similar way.

When you talk about a user needing to have privileges, it sounds like you mean privileges on the publishing database.
Butthen you talk about CREATE SUBSCRIPTION granting privileges, which would necessarily be on the subscriber database.
Canyou clarify what you have in mind? 

—
Mark Dilger
EnterpriseDB: http://www.enterprisedb.com
The Enterprise PostgreSQL Company






Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Mark Dilger <mark.dilger@enterprisedb.com> wrote:

> > On Nov 4, 2022, at 12:28 AM, Antonin Houska <ah@cybertec.at> wrote:
> >
> > I thought about the whole concept a bit more and I doubt if the PUBLICATION
> > privilege is the best approach. In particular, the user specified in CREATE
> > SUBSCRIPTION ... CONNECTION ... (say "subscription user") needs to have SELECT
> > privilege on the tables replicated. So if the DBA excludes some columns from
> > the publication's column list and sets the (publication) privileges in such a
> > way that the user cannot get the column values via other publications, the
> > user still can connect to the database directly and get values of the excluded
> > columns.
> >
> > As an alternative to the publication privileges, I think that the CREATE
> > SUBSCRIPTION command could grant ACL_SELECT automatically to the subscription
> > user on the individual columns contained in the publication column list, and
> > DROP SUBSCRIPTION would revoke that privilege.
> >
> > Of course a question is what to do if the replication user already has that
> > privilege on some columns: either the CREATE SUBSCRIPTION command should raise
> > ERROR, or we should introduce a new privilege (e.g. ACL_SELECT_PUB) for this
> > purpose, which would effectivelly be ACL_SELECT, but hidden from the users of
> > GRANT / REVOKE.
> >
> > If this approach was taken, the USAGE privilege on schema would need to be
> > granted / revoked in a similar way.
>
> When you talk about a user needing to have privileges, it sounds like you
> mean privileges on the publishing database.  But then you talk about CREATE
> SUBSCRIPTION granting privileges, which would necessarily be on the
> subscriber database.  Can you clarify what you have in mind?

Right, the privileges need to be added on the publishing side, but the user
that needs those privileges is specified on the subscription side. I didn't
think much in detail how it would work. The "subscription user" certainly
cannot connect to the publisher database and add grant the privileges to
itself. Perhaps some of the workers on the publisher side could do it on
startup.

--
Antonin Houska
Web: https://www.cybertec-postgresql.com



Re: Privileges on PUBLICATION

From
Peter Eisentraut
Date:
On 04.11.22 08:28, Antonin Houska wrote:
> I thought about the whole concept a bit more and I doubt if the PUBLICATION
> privilege is the best approach. In particular, the user specified in CREATE
> SUBSCRIPTION ... CONNECTION ... (say "subscription user") needs to have SELECT
> privilege on the tables replicated. So if the DBA excludes some columns from
> the publication's column list and sets the (publication) privileges in such a
> way that the user cannot get the column values via other publications, the
> user still can connect to the database directly and get values of the excluded
> columns.

Why are the SELECT privileges needed?  Maybe that's something to think 
about and maybe change.

> As an alternative to the publication privileges, I think that the CREATE
> SUBSCRIPTION command could grant ACL_SELECT automatically to the subscription
> user on the individual columns contained in the publication column list, and
> DROP SUBSCRIPTION would revoke that privilege.

I think that approach is weird and unusual.  Privileges and object 
creation should be separate operations.




Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:

> On 04.11.22 08:28, Antonin Houska wrote:
> > I thought about the whole concept a bit more and I doubt if the PUBLICATION
> > privilege is the best approach. In particular, the user specified in CREATE
> > SUBSCRIPTION ... CONNECTION ... (say "subscription user") needs to have SELECT
> > privilege on the tables replicated. So if the DBA excludes some columns from
> > the publication's column list and sets the (publication) privileges in such a
> > way that the user cannot get the column values via other publications, the
> > user still can connect to the database directly and get values of the excluded
> > columns.
>
> Why are the SELECT privileges needed?  Maybe that's something to think about
> and maybe change.

I haven't noticed an explanation in comments nor did I search in the mailing
list archives, but the question makes sense: the REPLICATION attribute of a
role is sufficient for streaming replication, so why should the logical
replication require additional privileges?

Technically the SELECT privilege is needed because the sync worker does
actually execute SELECT query on the published tables. However, I realize now
that it's not checked by the output plugin. Thus if SELECT is revoked from the
"subscription user" after the table has been synchronized, the replication
continues to work. So the necessity for the SELECT privilege might be an
omission rather than a design choice. (Even the documentation says that the
SELECT privilege is needed only for the initial synchronization [1], however
it does not tell why.)

> > As an alternative to the publication privileges, I think that the CREATE
> > SUBSCRIPTION command could grant ACL_SELECT automatically to the subscription
> > user on the individual columns contained in the publication column list, and
> > DROP SUBSCRIPTION would revoke that privilege.
>
> I think that approach is weird and unusual.  Privileges and object creation
> should be separate operations.

ok. Another approach would be to skip the check for the SELECT privilege (as
well as the check for the USAGE privilege on the corresponding schema) if
given column is being accessed via a publication which has it on its column
list and if the subscription user has the USAGE privilege on that publication.

So far I wasn't sure if we can do that because, if pg_upgrade grants the USAGE
privilege on all publications to the "public" role, the DBAs who relied on the
SELECT privileges might not notice that any role having the REPLICATION
attribute can access all the published tables after the upgrade. (pg_upgrade
can hardly do anything else because it has no information on the "subscription
users", so it cannot convert the SELECT privilege on tables to the USAGE
privileges on publications.)

But now that I see that the logical replication doesn't check the SELECT
privilege properly anyway, I think we can get rid of it.


[1] https://www.postgresql.org/docs/current/logical-replication-security.html

--
Antonin Houska
Web: https://www.cybertec-postgresql.com



Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Antonin Houska <ah@cybertec.at> wrote:

> Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:
> 
> > On 04.11.22 08:28, Antonin Houska wrote:
> > > I thought about the whole concept a bit more and I doubt if the PUBLICATION
> > > privilege is the best approach. In particular, the user specified in CREATE
> > > SUBSCRIPTION ... CONNECTION ... (say "subscription user") needs to have SELECT
> > > privilege on the tables replicated. So if the DBA excludes some columns from
> > > the publication's column list and sets the (publication) privileges in such a
> > > way that the user cannot get the column values via other publications, the
> > > user still can connect to the database directly and get values of the excluded
> > > columns.
> > 
> > Why are the SELECT privileges needed?  Maybe that's something to think about
> > and maybe change.
> 
> I haven't noticed an explanation in comments nor did I search in the mailing
> list archives, but the question makes sense: the REPLICATION attribute of a
> role is sufficient for streaming replication, so why should the logical
> replication require additional privileges?
> 
> Technically the SELECT privilege is needed because the sync worker does
> actually execute SELECT query on the published tables. However, I realize now
> that it's not checked by the output plugin. Thus if SELECT is revoked from the
> "subscription user" after the table has been synchronized, the replication
> continues to work. So the necessity for the SELECT privilege might be an
> omission rather than a design choice. (Even the documentation says that the
> SELECT privilege is needed only for the initial synchronization [1], however
> it does not tell why.)
> 
> > > As an alternative to the publication privileges, I think that the CREATE
> > > SUBSCRIPTION command could grant ACL_SELECT automatically to the subscription
> > > user on the individual columns contained in the publication column list, and
> > > DROP SUBSCRIPTION would revoke that privilege.
> > 
> > I think that approach is weird and unusual.  Privileges and object creation
> > should be separate operations.
> 
> ok. Another approach would be to skip the check for the SELECT privilege (as
> well as the check for the USAGE privilege on the corresponding schema) if
> given column is being accessed via a publication which has it on its column
> list and if the subscription user has the USAGE privilege on that publication.
> 
> So far I wasn't sure if we can do that because, if pg_upgrade grants the USAGE
> privilege on all publications to the "public" role, the DBAs who relied on the
> SELECT privileges might not notice that any role having the REPLICATION
> attribute can access all the published tables after the upgrade. (pg_upgrade
> can hardly do anything else because it has no information on the "subscription
> users", so it cannot convert the SELECT privilege on tables to the USAGE
> privileges on publications.)
> 
> But now that I see that the logical replication doesn't check the SELECT
> privilege properly anyway, I think we can get rid of it.

The attached version tries to do that - as you can see in 0001, the SELECT
privilege is not required for the walsender process.

I also added PUBLICATION_NAMES option to the COPY TO command so that the
publisher knows which publications are subject to the ACL check. Only data of
those publications are returned to the subscriber. (In the previous patch
version the ACL checks were performed on the subscriber side, but I that's not
ideal in terms of security.)

I also added the regression tests for publications, enhanced psql (the \dRp+
command) so that it displays the publication ACL and added a few missing
pieces of documentation.

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com


Attachment

Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Antonin Houska <ah@cybertec.at> wrote:

> Antonin Houska <ah@cybertec.at> wrote:
> 
> > Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:
> > 
> > > On 04.11.22 08:28, Antonin Houska wrote:
> > > > I thought about the whole concept a bit more and I doubt if the PUBLICATION
> > > > privilege is the best approach. In particular, the user specified in CREATE
> > > > SUBSCRIPTION ... CONNECTION ... (say "subscription user") needs to have SELECT
> > > > privilege on the tables replicated. So if the DBA excludes some columns from
> > > > the publication's column list and sets the (publication) privileges in such a
> > > > way that the user cannot get the column values via other publications, the
> > > > user still can connect to the database directly and get values of the excluded
> > > > columns.
> > > 
> > > Why are the SELECT privileges needed?  Maybe that's something to think about
> > > and maybe change.
> > 
> > I haven't noticed an explanation in comments nor did I search in the mailing
> > list archives, but the question makes sense: the REPLICATION attribute of a
> > role is sufficient for streaming replication, so why should the logical
> > replication require additional privileges?
> > 
> > Technically the SELECT privilege is needed because the sync worker does
> > actually execute SELECT query on the published tables. However, I realize now
> > that it's not checked by the output plugin. Thus if SELECT is revoked from the
> > "subscription user" after the table has been synchronized, the replication
> > continues to work. So the necessity for the SELECT privilege might be an
> > omission rather than a design choice. (Even the documentation says that the
> > SELECT privilege is needed only for the initial synchronization [1], however
> > it does not tell why.)
> > 
> > > > As an alternative to the publication privileges, I think that the CREATE
> > > > SUBSCRIPTION command could grant ACL_SELECT automatically to the subscription
> > > > user on the individual columns contained in the publication column list, and
> > > > DROP SUBSCRIPTION would revoke that privilege.
> > > 
> > > I think that approach is weird and unusual.  Privileges and object creation
> > > should be separate operations.
> > 
> > ok. Another approach would be to skip the check for the SELECT privilege (as
> > well as the check for the USAGE privilege on the corresponding schema) if
> > given column is being accessed via a publication which has it on its column
> > list and if the subscription user has the USAGE privilege on that publication.
> > 
> > So far I wasn't sure if we can do that because, if pg_upgrade grants the USAGE
> > privilege on all publications to the "public" role, the DBAs who relied on the
> > SELECT privileges might not notice that any role having the REPLICATION
> > attribute can access all the published tables after the upgrade. (pg_upgrade
> > can hardly do anything else because it has no information on the "subscription
> > users", so it cannot convert the SELECT privilege on tables to the USAGE
> > privileges on publications.)
> > 
> > But now that I see that the logical replication doesn't check the SELECT
> > privilege properly anyway, I think we can get rid of it.
> 
> The attached version tries to do that - as you can see in 0001, the SELECT
> privilege is not required for the walsender process.
> 
> I also added PUBLICATION_NAMES option to the COPY TO command so that the
> publisher knows which publications are subject to the ACL check. Only data of
> those publications are returned to the subscriber. (In the previous patch
> version the ACL checks were performed on the subscriber side, but I that's not
> ideal in terms of security.)
> 
> I also added the regression tests for publications, enhanced psql (the \dRp+
> command) so that it displays the publication ACL and added a few missing
> pieces of documentation.
> 

This is v4. The patch had to be rebased due to the commit 369f09e420.

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com


Attachment

Re: Privileges on PUBLICATION

From
Peter Eisentraut
Date:
On 16.12.22 17:37, Antonin Houska wrote:
> This is v4. The patch had to be rebased due to the commit 369f09e420.

I think what this patch set needs first of all is a comprehensive 
description of what it is trying to do, exactly what commands and 
behaviors it adds, what are some of the subtleties and corner cases, 
what are open issues and questions.  Some of that can be pieced together 
from this thread, but it should really be put in one place somewhere, 
ideally in the commit message and/or the documentation.  (The main 0002 
patch does not have any documentation.)  It looks like you have a lot of 
bases covered, but without a full description, it's difficult to tell.

Some points on the details:

* You can combine all five patches into one.  I don't think they are 
meant to be applied separately.  The 0001 looks like it was maybe meant 
to be used separately, but it's not clear.  Again, the overall 
description would help.

* There is a lot of code that is contingent on am_db_walsender.  We 
should avoid that.  In most cases, it doesn't seem necessary.  Or at 
least document the reasons.

* The term "aware" (of a publication ACL, of a relation) is used a bunch 
of times.  That's not a technical term, and the meaning of those phrases 
is not clear.  Make sure the documentation/comments are precise.

* I don't think using SPI is warranted here.  You can get the required 
information directly from the underlying functions.

* The places the privileges are ultimately checked is too unprincipled. 
The 0001 patch overrides a very low-level function, but the 0002 on the 
other hand checks the privileges by digging through the query structures 
by hand instead of letting the executor do it.  We need to find ways to 
handle that that is more consistent with what the code is currently 
doing instead of adding more layers to it above and below.

* The misc_sanity.out test output means you need to add a TOAST table to 
pg_publication.




Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:

> On 16.12.22 17:37, Antonin Houska wrote:
> > This is v4. The patch had to be rebased due to the commit 369f09e420.
> 
> I think what this patch set needs first of all is a comprehensive description
> of what it is trying to do, exactly what commands and behaviors it adds, what
> are some of the subtleties and corner cases, what are open issues and
> questions.  Some of that can be pieced together from this thread, but it
> should really be put in one place somewhere, ideally in the commit message
> and/or the documentation.  (The main 0002 patch does not have any
> documentation.)  It looks like you have a lot of bases covered, but without a
> full description, it's difficult to tell.
> 
> Some points on the details:
> 
> * You can combine all five patches into one.  I don't think they are meant to
>   be applied separately.  The 0001 looks like it was maybe meant to be used
>   separately, but it's not clear.  Again, the overall description would help.
> 
> * There is a lot of code that is contingent on am_db_walsender.  We should
>   avoid that.  In most cases, it doesn't seem necessary.  Or at least document
>  the reasons.
> 
> * The term "aware" (of a publication ACL, of a relation) is used a bunch of
>   times.  That's not a technical term, and the meaning of those phrases is not
>  clear.  Make sure the documentation/comments are precise.
> 
> * I don't think using SPI is warranted here.  You can get the required
>   information directly from the underlying functions.
> 
> * The places the privileges are ultimately checked is too unprincipled. The
>   0001 patch overrides a very low-level function, but the 0002 on the other
>   hand checks the privileges by digging through the query structures by hand
>   instead of letting the executor do it.  We need to find ways to handle that
>   that is more consistent with what the code is currently doing instead of
>  adding more layers to it above and below.
> 
> * The misc_sanity.out test output means you need to add a TOAST table to
>   pg_publication.
> 

Thanks for your review. Attached is a new version that tries to address your
findings.

I reworked the patch a bit, especially the handling of the PUBLICATION_NAMES
of the COPY TO command, so that compatibility with older subscribers is not
broken. The compatibility is actually the hardest part.

0001 only move some code into functions. I think it's better to do
this kind of thing separate so that the actual changes are easier to read.

The TODO comment in 0002 is related to [1].

[1] https://www.postgresql.org/message-id/3472.1675251957%40antos

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com

From f3d614d1ef6b74bc211cfc17c699ce6bd4a0f4d5 Mon Sep 17 00:00:00 2001
From: Antonin Houska <ah@cybertec.at>
Date: Wed, 1 Feb 2023 12:49:57 +0100
Subject: [PATCH 1/2] Move some code into functions.

This is only a preparation for a patch that introduces USAGE privilege on
publications. It should make the following diff a little bit easier to read.
---
 src/backend/catalog/pg_publication.c        | 236 +++++++++++++++++---
 src/backend/commands/copy.c                 |  77 +------
 src/backend/commands/copyto.c               |  88 ++++++++
 src/backend/replication/pgoutput/pgoutput.c | 139 +-----------
 src/include/catalog/pg_publication.h        |   6 +
 src/include/commands/copy.h                 |   2 +
 6 files changed, 307 insertions(+), 241 deletions(-)

diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index a98fcad421..7f6024b7a5 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -1025,6 +1025,208 @@ GetPublicationByName(const char *pubname, bool missing_ok)
     return OidIsValid(oid) ? GetPublication(oid) : NULL;
 }
 
+/*
+ * Get the mapping for given publication and relation.
+ */
+void
+GetPublicationRelationMapping(Oid pubid, Oid relid,
+                              Datum *attrs, bool *attrs_isnull,
+                              Datum *qual, bool *qual_isnull)
+{
+    Publication *publication;
+    HeapTuple    pubtuple = NULL;
+    Oid            schemaid = get_rel_namespace(relid);
+
+    publication = GetPublication(pubid);
+
+    /*
+     * We don't consider row filters or column lists for FOR ALL TABLES or
+     * FOR TABLES IN SCHEMA publications.
+     */
+    if (!publication->alltables &&
+        !SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
+                               ObjectIdGetDatum(schemaid),
+                               ObjectIdGetDatum(publication->oid)))
+        pubtuple = SearchSysCacheCopy2(PUBLICATIONRELMAP,
+                                       ObjectIdGetDatum(relid),
+                                       ObjectIdGetDatum(publication->oid));
+
+    if (HeapTupleIsValid(pubtuple))
+    {
+        /* Lookup the column list attribute. */
+        *attrs = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
+                                 Anum_pg_publication_rel_prattrs,
+                                 attrs_isnull);
+
+        /* Null indicates no filter. */
+        *qual = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
+                                Anum_pg_publication_rel_prqual,
+                                qual_isnull);
+    }
+    else
+    {
+        *attrs_isnull = true;
+        *qual_isnull = true;
+    }
+}
+/*
+ * Pick those publications from a list which should actually be used to
+ * publish given relation and return them.
+ *
+ * If publish_as_relid_p is passed, the relation whose tuple descriptor should
+ * be used to publish the data is stored in *publish_as_relid_p.
+ *
+ * If pubactions is passed, update the structure according to the matching
+ * publications.
+ */
+List *
+GetEffectiveRelationPublications(Oid relid, List *publications,
+                                 Oid *publish_as_relid_p,
+                                 PublicationActions *pubactions)
+{
+    Oid            schemaId = get_rel_namespace(relid);
+    List       *pubids = GetRelationPublications(relid);
+    /*
+     * We don't acquire a lock on the namespace system table as we build the
+     * cache entry using a historic snapshot and all the later changes are
+     * absorbed while decoding WAL.
+     */
+    List       *schemaPubids = GetSchemaPublications(schemaId);
+    ListCell   *lc;
+    Oid            publish_as_relid = relid;
+    int            publish_ancestor_level = 0;
+    bool        am_partition = get_rel_relispartition(relid);
+    char        relkind = get_rel_relkind(relid);
+    List       *rel_publications = NIL;
+
+    foreach(lc, publications)
+    {
+        Publication *pub = lfirst(lc);
+        bool        publish = false;
+
+        /*
+         * Under what relid should we publish changes in this publication?
+         * We'll use the top-most relid across all publications. Also track
+         * the ancestor level for this publication.
+         */
+        Oid    pub_relid = relid;
+        int    ancestor_level = 0;
+
+        /*
+         * If this is a FOR ALL TABLES publication, pick the partition root
+         * and set the ancestor level accordingly.
+         */
+        if (pub->alltables)
+        {
+            publish = true;
+            if (pub->pubviaroot && am_partition)
+            {
+                List       *ancestors = get_partition_ancestors(relid);
+
+                pub_relid = llast_oid(ancestors);
+                ancestor_level = list_length(ancestors);
+            }
+        }
+
+        if (!publish)
+        {
+            bool        ancestor_published = false;
+
+            /*
+             * For a partition, check if any of the ancestors are published.
+             * If so, note down the topmost ancestor that is published via
+             * this publication, which will be used as the relation via which
+             * to publish the partition's changes.
+             */
+            if (am_partition)
+            {
+                Oid            ancestor;
+                int            level;
+                List       *ancestors = get_partition_ancestors(relid);
+
+                ancestor = GetTopMostAncestorInPublication(pub->oid,
+                                                           ancestors,
+                                                           &level);
+
+                if (ancestor != InvalidOid)
+                {
+                    ancestor_published = true;
+                    if (pub->pubviaroot)
+                    {
+                        pub_relid = ancestor;
+                        ancestor_level = level;
+                    }
+                }
+            }
+
+            if (list_member_oid(pubids, pub->oid) ||
+                list_member_oid(schemaPubids, pub->oid) ||
+                ancestor_published)
+                publish = true;
+        }
+
+        /*
+         * If the relation is to be published, determine actions to publish,
+         * and list of columns, if appropriate.
+         *
+         * Don't publish changes for partitioned tables, because publishing
+         * those of its partitions suffices, unless partition changes won't be
+         * published due to pubviaroot being set.
+         */
+        if (publish &&
+            (relkind != RELKIND_PARTITIONED_TABLE || pub->pubviaroot))
+        {
+            if (pubactions)
+            {
+                pubactions->pubinsert |= pub->pubactions.pubinsert;
+                pubactions->pubupdate |= pub->pubactions.pubupdate;
+                pubactions->pubdelete |= pub->pubactions.pubdelete;
+                pubactions->pubtruncate |= pub->pubactions.pubtruncate;
+            }
+
+            /*
+             * We want to publish the changes as the top-most ancestor across
+             * all publications. So we need to check if the already calculated
+             * level is higher than the new one. If yes, we can ignore the new
+             * value (as it's a child). Otherwise the new value is an
+             * ancestor, so we keep it.
+             */
+            if (publish_ancestor_level > ancestor_level)
+                continue;
+
+            /*
+             * If we found an ancestor higher up in the tree, discard the list
+             * of publications through which we replicate it, and use the new
+             * ancestor.
+             */
+            if (publish_ancestor_level < ancestor_level)
+            {
+                publish_as_relid = pub_relid;
+                publish_ancestor_level = ancestor_level;
+
+                /* reset the publication list for this relation */
+                rel_publications = NIL;
+            }
+            else
+            {
+                /* Same ancestor level, has to be the same OID. */
+                Assert(publish_as_relid == pub_relid);
+            }
+
+            /* Track publications for this ancestor. */
+            rel_publications = lappend(rel_publications, pub);
+        }
+    }
+
+    list_free(pubids);
+    list_free(schemaPubids);
+
+    if (publish_as_relid_p)
+        *publish_as_relid_p = publish_as_relid;
+
+    return rel_publications;
+}
+
 /*
  * Returns information of tables in a publication.
  */
@@ -1108,10 +1310,8 @@ pg_get_publication_tables(PG_FUNCTION_ARGS)
 
     if (funcctx->call_cntr < list_length(tables))
     {
-        HeapTuple    pubtuple = NULL;
         HeapTuple    rettuple;
         Oid            relid = list_nth_oid(tables, funcctx->call_cntr);
-        Oid            schemaid = get_rel_namespace(relid);
         Datum        values[NUM_PUBLICATION_TABLES_ELEM] = {0};
         bool        nulls[NUM_PUBLICATION_TABLES_ELEM] = {0};
 
@@ -1123,35 +1323,9 @@ pg_get_publication_tables(PG_FUNCTION_ARGS)
 
         values[0] = ObjectIdGetDatum(relid);
 
-        /*
-         * We don't consider row filters or column lists for FOR ALL TABLES or
-         * FOR TABLES IN SCHEMA publications.
-         */
-        if (!publication->alltables &&
-            !SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
-                                   ObjectIdGetDatum(schemaid),
-                                   ObjectIdGetDatum(publication->oid)))
-            pubtuple = SearchSysCacheCopy2(PUBLICATIONRELMAP,
-                                           ObjectIdGetDatum(relid),
-                                           ObjectIdGetDatum(publication->oid));
-
-        if (HeapTupleIsValid(pubtuple))
-        {
-            /* Lookup the column list attribute. */
-            values[1] = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
-                                        Anum_pg_publication_rel_prattrs,
-                                        &(nulls[1]));
-
-            /* Null indicates no filter. */
-            values[2] = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
-                                        Anum_pg_publication_rel_prqual,
-                                        &(nulls[2]));
-        }
-        else
-        {
-            nulls[1] = true;
-            nulls[2] = true;
-        }
+        GetPublicationRelationMapping(publication->oid, relid,
+                                      &values[1], &nulls[1],
+                                      &values[2], &nulls[2]);
 
         /* Show all columns when the column list is not specified. */
         if (nulls[1] == true)
diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c
index e34f583ea7..153eae0379 100644
--- a/src/backend/commands/copy.c
+++ b/src/backend/commands/copy.c
@@ -177,88 +177,13 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
          */
         if (check_enable_rls(relid, InvalidOid, false) == RLS_ENABLED)
         {
-            SelectStmt *select;
-            ColumnRef  *cr;
-            ResTarget  *target;
-            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
-             *
-             * If no columns are specified in the attribute list of the COPY
-             * command, then the target list is 'all' columns. Therefore, '*'
-             * should be used as the target list for the resulting SELECT
-             * statement.
-             *
-             * In the case that columns are specified in the attribute list,
-             * create a ColumnRef and ResTarget for each column and add them
-             * to the target list for the resulting SELECT statement.
-             */
-            if (!stmt->attlist)
-            {
-                cr = makeNode(ColumnRef);
-                cr->fields = list_make1(makeNode(A_Star));
-                cr->location = -1;
-
-                target = makeNode(ResTarget);
-                target->name = NULL;
-                target->indirection = NIL;
-                target->val = (Node *) cr;
-                target->location = -1;
-
-                targetList = list_make1(target);
-            }
-            else
-            {
-                ListCell   *lc;
-
-                foreach(lc, stmt->attlist)
-                {
-                    /*
-                     * Build the ColumnRef for each column.  The ColumnRef
-                     * 'fields' property is a String node that corresponds to
-                     * the column name respectively.
-                     */
-                    cr = makeNode(ColumnRef);
-                    cr->fields = list_make1(lfirst(lc));
-                    cr->location = -1;
-
-                    /* Build the ResTarget and add the ColumnRef to it. */
-                    target = makeNode(ResTarget);
-                    target->name = NULL;
-                    target->indirection = NIL;
-                    target->val = (Node *) cr;
-                    target->location = -1;
-
-                    /* Add each column to the SELECT statement's target list */
-                    targetList = lappend(targetList, target);
-                }
-            }
-
-            /*
-             * Build RangeVar for from clause, fully qualified based on the
-             * relation which we have opened and locked.
-             */
-            from = makeRangeVar(get_namespace_name(RelationGetNamespace(rel)),
-                                pstrdup(RelationGetRelationName(rel)),
-                                -1);
-
-            /* Build query */
-            select = makeNode(SelectStmt);
-            select->targetList = targetList;
-            select->fromClause = list_make1(from);
-
-            query = makeNode(RawStmt);
-            query->stmt = (Node *) select;
-            query->stmt_location = stmt_location;
-            query->stmt_len = stmt_len;
+            query = CreateCopyToQuery(stmt, rel, stmt_location, stmt_len);
 
             /*
              * Close the relation for now, but keep the lock on it to prevent
diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c
index 8043b4e9b1..ad79a56f75 100644
--- a/src/backend/commands/copyto.c
+++ b/src/backend/commands/copyto.c
@@ -32,6 +32,7 @@
 #include "libpq/pqformat.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
 #include "optimizer/optimizer.h"
 #include "pgstat.h"
 #include "rewrite/rewriteHandler.h"
@@ -339,6 +340,93 @@ EndCopy(CopyToState cstate)
     pfree(cstate);
 }
 
+/*
+ * Turn "COPY table_name TO" form into "COPY (query) TO".
+ */
+RawStmt *
+CreateCopyToQuery(const CopyStmt *stmt, Relation rel, int stmt_location,
+                  int stmt_len)
+{
+    SelectStmt *select;
+    ColumnRef  *cr;
+    ResTarget  *target;
+    RangeVar   *from;
+    List       *targetList = NIL;
+    RawStmt    *query = NULL;
+
+    /*
+     * Build target list
+     *
+     * If no columns are specified in the attribute list of the COPY command,
+     * then the target list is 'all' columns. Therefore, '*' should be used as
+     * the target list for the resulting SELECT statement.
+     *
+     * In the case that columns are specified in the attribute list, create a
+     * ColumnRef and ResTarget for each column and add them to the target list
+     * for the resulting SELECT statement.
+     */
+    if (!stmt->attlist)
+    {
+        cr = makeNode(ColumnRef);
+        cr->fields = list_make1(makeNode(A_Star));
+        cr->location = -1;
+
+        target = makeNode(ResTarget);
+        target->name = NULL;
+        target->indirection = NIL;
+        target->val = (Node *) cr;
+        target->location = -1;
+
+        targetList = list_make1(target);
+    }
+    else
+    {
+        ListCell   *lc;
+
+        foreach(lc, stmt->attlist)
+        {
+            /*
+             * Build the ColumnRef for each column.  The ColumnRef 'fields'
+             * property is a String node that corresponds to the column name
+             * respectively.
+             */
+            cr = makeNode(ColumnRef);
+            cr->fields = list_make1(lfirst(lc));
+            cr->location = -1;
+
+            /* Build the ResTarget and add the ColumnRef to it. */
+            target = makeNode(ResTarget);
+            target->name = NULL;
+            target->indirection = NIL;
+            target->val = (Node *) cr;
+            target->location = -1;
+
+            /* Add each column to the SELECT statement's target list */
+            targetList = lappend(targetList, target);
+        }
+    }
+
+    /*
+     * Build RangeVar for from clause, fully qualified based on the relation
+     * which we have opened and locked.
+     */
+    from = makeRangeVar(get_namespace_name(RelationGetNamespace(rel)),
+                        pstrdup(RelationGetRelationName(rel)),
+                        -1);
+
+    /* Build query */
+    select = makeNode(SelectStmt);
+    select->targetList = targetList;
+    select->fromClause = list_make1(from);
+
+    query = makeNode(RawStmt);
+    query->stmt = (Node *) select;
+    query->stmt_location = stmt_location;
+    query->stmt_len = stmt_len;
+
+    return query;
+}
+
 /*
  * Setup CopyToState to read tuples from a table or a query for COPY TO.
  *
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 1a80d67bb9..6e0b51ada4 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -2050,21 +2050,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
     /* Validate the entry */
     if (!entry->replicate_valid)
     {
-        Oid            schemaId = get_rel_namespace(relid);
-        List       *pubids = GetRelationPublications(relid);
-
-        /*
-         * We don't acquire a lock on the namespace system table as we build
-         * the cache entry using a historic snapshot and all the later changes
-         * are absorbed while decoding WAL.
-         */
-        List       *schemaPubids = GetSchemaPublications(schemaId);
-        ListCell   *lc;
-        Oid            publish_as_relid = relid;
-        int            publish_ancestor_level = 0;
-        bool        am_partition = get_rel_relispartition(relid);
-        char        relkind = get_rel_relkind(relid);
-        List       *rel_publications = NIL;
+        List    *rel_publications;
 
         /* Reload publications if needed before use. */
         if (!publications_valid)
@@ -2127,123 +2113,10 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
          * but here we only need to consider ones that the subscriber
          * requested.
          */
-        foreach(lc, data->publications)
-        {
-            Publication *pub = lfirst(lc);
-            bool        publish = false;
-
-            /*
-             * Under what relid should we publish changes in this publication?
-             * We'll use the top-most relid across all publications. Also
-             * track the ancestor level for this publication.
-             */
-            Oid            pub_relid = relid;
-            int            ancestor_level = 0;
-
-            /*
-             * If this is a FOR ALL TABLES publication, pick the partition
-             * root and set the ancestor level accordingly.
-             */
-            if (pub->alltables)
-            {
-                publish = true;
-                if (pub->pubviaroot && am_partition)
-                {
-                    List       *ancestors = get_partition_ancestors(relid);
-
-                    pub_relid = llast_oid(ancestors);
-                    ancestor_level = list_length(ancestors);
-                }
-            }
-
-            if (!publish)
-            {
-                bool        ancestor_published = false;
-
-                /*
-                 * For a partition, check if any of the ancestors are
-                 * published.  If so, note down the topmost ancestor that is
-                 * published via this publication, which will be used as the
-                 * relation via which to publish the partition's changes.
-                 */
-                if (am_partition)
-                {
-                    Oid            ancestor;
-                    int            level;
-                    List       *ancestors = get_partition_ancestors(relid);
-
-                    ancestor = GetTopMostAncestorInPublication(pub->oid,
-                                                               ancestors,
-                                                               &level);
-
-                    if (ancestor != InvalidOid)
-                    {
-                        ancestor_published = true;
-                        if (pub->pubviaroot)
-                        {
-                            pub_relid = ancestor;
-                            ancestor_level = level;
-                        }
-                    }
-                }
-
-                if (list_member_oid(pubids, pub->oid) ||
-                    list_member_oid(schemaPubids, pub->oid) ||
-                    ancestor_published)
-                    publish = true;
-            }
-
-            /*
-             * If the relation is to be published, determine actions to
-             * publish, and list of columns, if appropriate.
-             *
-             * Don't publish changes for partitioned tables, because
-             * publishing those of its partitions suffices, unless partition
-             * changes won't be published due to pubviaroot being set.
-             */
-            if (publish &&
-                (relkind != RELKIND_PARTITIONED_TABLE || pub->pubviaroot))
-            {
-                entry->pubactions.pubinsert |= pub->pubactions.pubinsert;
-                entry->pubactions.pubupdate |= pub->pubactions.pubupdate;
-                entry->pubactions.pubdelete |= pub->pubactions.pubdelete;
-                entry->pubactions.pubtruncate |= pub->pubactions.pubtruncate;
-
-                /*
-                 * We want to publish the changes as the top-most ancestor
-                 * across all publications. So we need to check if the already
-                 * calculated level is higher than the new one. If yes, we can
-                 * ignore the new value (as it's a child). Otherwise the new
-                 * value is an ancestor, so we keep it.
-                 */
-                if (publish_ancestor_level > ancestor_level)
-                    continue;
-
-                /*
-                 * If we found an ancestor higher up in the tree, discard the
-                 * list of publications through which we replicate it, and use
-                 * the new ancestor.
-                 */
-                if (publish_ancestor_level < ancestor_level)
-                {
-                    publish_as_relid = pub_relid;
-                    publish_ancestor_level = ancestor_level;
-
-                    /* reset the publication list for this relation */
-                    rel_publications = NIL;
-                }
-                else
-                {
-                    /* Same ancestor level, has to be the same OID. */
-                    Assert(publish_as_relid == pub_relid);
-                }
-
-                /* Track publications for this ancestor. */
-                rel_publications = lappend(rel_publications, pub);
-            }
-        }
-
-        entry->publish_as_relid = publish_as_relid;
+        rel_publications = GetEffectiveRelationPublications(relid,
+                                                            data->publications,
+                                                            &entry->publish_as_relid,
+                                                            &entry->pubactions);
 
         /*
          * Initialize the tuple slot, map, and row filter. These are only used
@@ -2262,8 +2135,6 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
             pgoutput_column_list_init(data, rel_publications, entry);
         }
 
-        list_free(pubids);
-        list_free(schemaPubids);
         list_free(rel_publications);
 
         entry->replicate_valid = true;
diff --git a/src/include/catalog/pg_publication.h b/src/include/catalog/pg_publication.h
index 6ecaa2a01e..dab5bc8444 100644
--- a/src/include/catalog/pg_publication.h
+++ b/src/include/catalog/pg_publication.h
@@ -113,6 +113,12 @@ typedef struct PublicationRelInfo
 extern Publication *GetPublication(Oid pubid);
 extern Publication *GetPublicationByName(const char *pubname, bool missing_ok);
 extern List *GetRelationPublications(Oid relid);
+extern void GetPublicationRelationMapping(Oid pubid, Oid relid,
+                                          Datum *attrs, bool *attrs_isnull,
+                                          Datum *qual, bool *qual_isnull);
+extern List *GetEffectiveRelationPublications(Oid relid, List *publications,
+                                              Oid *publish_as_relid_p,
+                                              PublicationActions *pubactions);
 
 /*---------
  * Expected values for pub_partopt parameter of GetRelationPublications(),
diff --git a/src/include/commands/copy.h b/src/include/commands/copy.h
index 8e5f6ff148..dd45dba465 100644
--- a/src/include/commands/copy.h
+++ b/src/include/commands/copy.h
@@ -90,6 +90,8 @@ extern DestReceiver *CreateCopyDestReceiver(void);
 /*
  * internal prototypes
  */
+extern RawStmt *CreateCopyToQuery(const CopyStmt *stmt, Relation rel,
+                                  int stmt_location, int stmt_len);
 extern CopyToState BeginCopyTo(ParseState *pstate, Relation rel, RawStmt *raw_query,
                                Oid queryRelId, const char *filename, bool is_program,
                                copy_data_dest_cb data_dest_cb, List *attnamelist, List *options);
-- 
2.31.1

From f29b1daf4dfaba99facf321500f83dae414f9fa7 Mon Sep 17 00:00:00 2001
From: Antonin Houska <ah@cybertec.at>
Date: Wed, 1 Feb 2023 12:49:58 +0100
Subject: [PATCH 2/2] Implement the USAGE privilege on PUBLICATION.

Publication row filters and column lists can be used to prevent subsets of
data from being replicated via the logical replication system. These features
can address performance issues, but currently should not be used for security
purposes, such as hiding sensitive data from the subscribers. The problem is
that any subscriber can get the data from any publication, so if the sensitive
data is deliberately not published via one publication, it can still be
available via another one (supposedly created for another subscriber).

This patch adds an ACL column to the pg_publication catalog, implements the
corresponding checks and enhances the GRANT and REVOKE commands to grant and
revoke the USAGE privilege on publication to / from roles. The USAGE privilege
is initially granted to the PUBLIC group (so that existing configurations
don't get broken) but the user can revoke it and grant it only to individual
subscription users (i.e. users mentioned in the subscription connection
configuration). Thus the publisher instance can reject to send data that given
subscriber is not supposed to receive.

Obviously, the publication privileges are checked on the publisher side,
otherwise the implementation wouldn't be secure. The output plugin
(pgoutput.c) is the easy part because it already does receive the list of
publications whose data it should send to the subscriber. The initial table
synchronization is a little bit tricky because so far the "tablesync worker"
(running on the subscriber side) was responsible for constructing the SQL
query for the COPY TO command, which is executed on the publisher side.

This patch adds a new option PUBLICATION_NAMES to the COPY TO command. The
subscriber uses it to pass a list of publications to the publisher. The
publisher checks if the subscription user has the USAGE privilege on each
publication, retrieves the corresponding data (i.e. rows matching the row
filters of the publications) and sends it to the subscriber.

Since the publisher and subscriber instances can be on different major
versions of postgres, and since old subscribers cannot send the publication
names during the initial table synchronization, a new configuration variable
"publication_security" was added. The default value is "off", meaning that the
publisher does not require the COPY TO command to contain the
PUBLICATION_NAMES option. If the option is passed yet, the publisher does not
check the privileges on the listed publications, but it does perform row
filtering according to the publication filters. Thus upgrade of the publisher
instance does not break anything.

Once all the subscribers have migrated to the postgres version that supports
this feature, this variable should be set to "on". At that moment the
publisher starts to require the presence of the PUBLICATION_NAMES option in
the COPY TO command, as long as the COPY TO is executed by a role which has
the REPLICATION privilege. (Role w/o the REPLICATION privilege aren't
currently allowed to use the PUBLICATION_NAMES option.)
---
 doc/src/sgml/catalogs.sgml                    |   9 +
 doc/src/sgml/config.sgml                      |  28 ++
 doc/src/sgml/ddl.sgml                         |  14 +
 doc/src/sgml/logical-replication.sgml         |  72 +--
 doc/src/sgml/ref/copy.sgml                    |  36 ++
 doc/src/sgml/ref/grant.sgml                   |   9 +-
 src/backend/catalog/aclchk.c                  |  22 +
 src/backend/catalog/namespace.c               |  34 +-
 src/backend/catalog/objectaddress.c           |   2 +-
 src/backend/catalog/pg_publication.c          |  20 +-
 src/backend/commands/copy.c                   | 169 ++++++-
 src/backend/commands/copyto.c                 | 224 ++++++++-
 src/backend/commands/publicationcmds.c        |   2 +
 src/backend/executor/execMain.c               |   4 +-
 src/backend/parser/gram.y                     |   8 +
 src/backend/replication/logical/tablesync.c   |  91 ++--
 src/backend/replication/pgoutput/pgoutput.c   |   3 +
 src/backend/replication/walsender.c           |   6 +
 src/backend/utils/adt/acl.c                   |  51 +++
 src/backend/utils/misc/guc_tables.c           |  12 +
 src/backend/utils/misc/postgresql.conf.sample |   6 +
 src/bin/pg_dump/dumputils.c                   |   2 +
 src/bin/pg_dump/pg_dump.c                     |  47 +-
 src/bin/pg_dump/pg_dump.h                     |   1 +
 src/bin/psql/describe.c                       |  11 +
 src/bin/psql/tab-complete.c                   |   3 +
 src/include/catalog/pg_proc.dat               |   3 +
 src/include/catalog/pg_publication.h          |  10 +
 src/include/commands/copy.h                   |   7 +-
 src/include/nodes/parsenodes.h                |   4 +-
 src/include/replication/logicalproto.h        |   1 +
 src/include/utils/acl.h                       |   1 +
 src/include/utils/guc_tables.h                |   1 +
 .../test_copy_callbacks/test_copy_callbacks.c |   2 +-
 src/test/regress/expected/copy.out            |  52 +++
 src/test/regress/expected/publication.out     | 424 ++++++++++--------
 src/test/regress/sql/copy.sql                 |  36 ++
 src/test/regress/sql/publication.sql          |  28 ++
 src/test/subscription/t/027_nosuperuser.pl    |  58 ++-
 39 files changed, 1205 insertions(+), 308 deletions(-)

diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index c1e4048054..acc8db53c3 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -6343,6 +6343,15 @@ SCRAM-SHA-256$<replaceable><iteration count></replaceable>:<replaceable>&l
        publication instead of its own.
       </para></entry>
      </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pubacl</structfield> <type>aclitem[]</type>
+      </para>
+      <para>
+       Access privileges; see <xref linkend="ddl-priv"/> for details
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 1cf53c74ea..a1797463aa 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -4958,6 +4958,34 @@ ANY <replaceable class="parameter">num_sync</replaceable> ( <replaceable class="
      </variablelist>
     </sect2>
 
+    <sect2 id="runtime-config-replication-publisher">
+     <title>Publishers</title>
+
+     <para>
+      These settings control the behavior of a logical replication publisher.
+      Their values on the subscriber are irrelevant.
+     </para>
+
+     <variablelist>
+
+     <varlistentry id="guc-publication-security" xreflabel="publication_security">
+      <term><varname>publication_security</varname> (<type>boolean</type>)
+       <indexterm>
+        <primary><varname>publication_security</varname> configuration parameter</primary>
+        <secondary>in a publisher</secondary>
+       </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Specifies whether the publisher should check the publication
+        privileges before it sends data to the subscriber.  See
+        <xref linkend="logical-replication-security"/> for more details.
+       </para>
+      </listitem>
+     </varlistentry>
+     </variablelist>
+    </sect2>
+
     <sect2 id="runtime-config-replication-subscriber">
      <title>Subscribers</title>
 
diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index 8dc8d7a0ce..2ebc6d8e32 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -1962,6 +1962,13 @@ REVOKE ALL ON accounts FROM PUBLIC;
        statements that have previously performed this lookup, so this is not
        a completely secure way to prevent object access.
       </para>
+      <para>
+       For publications, allows logical replication via particular
+       publication. The user specified in
+       the <link linkend="sql-createsubscription"><command>CREATE
+       SUBSCRIPTION</command></link> command must have this privilege on all
+       publications listed in that command.
+      </para>
       <para>
        For sequences, allows use of the
        <function>currval</function> and <function>nextval</function> functions.
@@ -2155,6 +2162,7 @@ REVOKE ALL ON accounts FROM PUBLIC;
        <literal>FOREIGN DATA WRAPPER</literal>,
        <literal>FOREIGN SERVER</literal>,
        <literal>LANGUAGE</literal>,
+       <literal>PUBLICATION</literal>,
        <literal>SCHEMA</literal>,
        <literal>SEQUENCE</literal>,
        <literal>TYPE</literal>
@@ -2251,6 +2259,12 @@ REVOKE ALL ON accounts FROM PUBLIC;
       <entry>none</entry>
       <entry><literal>\dconfig+</literal></entry>
      </row>
+     <row>
+      <entry><literal>PUBLICATION</literal></entry>
+      <entry><literal>U</literal></entry>
+      <entry>U</entry>
+      <entry><literal>\dRp+</literal></entry>
+     </row>
      <row>
       <entry><literal>SCHEMA</literal></entry>
       <entry><literal>UC</literal></entry>
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 1bd5660c87..64774e68cd 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -898,26 +898,26 @@ CREATE PUBLICATION
     <command>psql</command> can be used to show the row filter expressions (if
     defined) for each publication.
 <programlisting>
-test_pub=# \dRp+
+  test_pub=# \dRp+
                                Publication p1
-  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root
-----------+------------+---------+---------+---------+-----------+----------
- postgres | f          | t       | t       | t       | t         | f
+  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+----------+------------+---------+---------+---------+-----------+------------------------------
+ postgres | f          | t       | t       | t       | t         | f        |
 Tables:
     "public.t1" WHERE ((a > 5) AND (c = 'NSW'::text))
 
                                Publication p2
-  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root
-----------+------------+---------+---------+---------+-----------+----------
- postgres | f          | t       | t       | t       | t         | f
+  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+----------+------------+---------+---------+---------+-----------+------------------------------
+ postgres | f          | t       | t       | t       | t         | f        |
 Tables:
     "public.t1"
     "public.t2" WHERE (e = 99)
 
                                Publication p3
-  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root
-----------+------------+---------+---------+---------+-----------+----------
- postgres | f          | t       | t       | t       | t         | f
+  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+----------+------------+---------+---------+---------+-----------+------------------------------
+ postgres | f          | t       | t       | t       | t         | f        |
 Tables:
     "public.t2" WHERE (d = 10)
     "public.t3" WHERE (g = 10)
@@ -1259,10 +1259,11 @@ test_sub=# SELECT * FROM child ORDER BY a;
 
   <para>
    The choice of columns can be based on behavioral or performance reasons.
-   However, do not rely on this feature for security: a malicious subscriber
-   is able to obtain data from columns that are not specifically
-   published.  If security is a consideration, protections can be applied
-   at the publisher side.
+   However, if you want to use this feature for security, please consider
+   using the privileges on publication, as explained in
+   <xref linkend="logical-replication-security"/>. Otherwise a malicious
+   subscriber may be able to use other publications to obtain data from
+   columns that are not specifically published via your publication.
   </para>
 
   <para>
@@ -1360,9 +1361,9 @@ CREATE PUBLICATION
 <programlisting>
 test_pub=# \dRp+
                                Publication p1
-  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root
-----------+------------+---------+---------+---------+-----------+----------
- postgres | f          | t       | t       | t       | t         | f
+  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+----------+------------+---------+---------+---------+-----------+------------------------------
+ postgres | f          | t       | t       | t       | t         | f        |
 Tables:
     "public.t1" (id, a, b, d)
 </programlisting></para>
@@ -1724,12 +1725,6 @@ CONTEXT:  processing remote data for replication origin "pg_16395" during "INSER
    and it must have the <literal>LOGIN</literal> attribute.
   </para>
 
-  <para>
-   In order to be able to copy the initial table data, the role used for the
-   replication connection must have the <literal>SELECT</literal> privilege on
-   a published table (or be a superuser).
-  </para>
-
   <para>
    To create a publication, the user must have the <literal>CREATE</literal>
    privilege in the database.
@@ -1743,16 +1738,25 @@ CONTEXT:  processing remote data for replication origin "pg_16395" during "INSER
   </para>
 
   <para>
-   There are currently no privileges on publications.  Any subscription (that
-   is able to connect) can access any publication.  Thus, if you intend to
-   hide some information from particular subscribers, such as by using row
-   filters or column lists, or by not adding the whole table to the
-   publication, be aware that other publications in the same database could
-   expose the same information.  Publication privileges might be added to
-   <productname>PostgreSQL</productname> in the future to allow for
-   finer-grained access control.
+   To replicate data, the role used for the replication connection must have
+   the <literal>USAGE</literal> privilege on the publication. In such a case,
+   the subscription role needs neither the <literal>SELECT</literal>
+   privileges on the replicated tables nor the <literal>USAGE</literal>
+   privilege on the containing schemas.
   </para>
 
+  <note>
+   <para>
+    The <literal>USAGE</literal> privilege on publication is only checked if
+    the <link linkend="guc-publication-security"><varname>publication_security</varname></link>
+    configuration parameter is set. The default is <literal>off</literal>. It
+    should only be set to <literal>on</literal> if all the subscribers are
+    on <productname>PostgreSQL</productname> server version 16 or later. The
+    older versions do not send the publication names for the initial table
+    synchronization, so they would fail to receive the data.
+   </para>
+  </note>
+
   <para>
    To create a subscription, the user must be a superuser.
   </para>
@@ -1812,6 +1816,12 @@ CONTEXT:  processing remote data for replication origin "pg_16395" during "INSER
     <link linkend="guc-wal-sender-timeout"><varname>wal_sender_timeout</varname></link>.
    </para>
 
+   <para>
+    <link linkend="guc-publication-security"><varname>publication_security</varname></link>
+    must be set to <literal>on</literal> if the publisher is supposed to check
+    the publication privileges.
+   </para>
+
   </sect2>
 
   <sect2 id="logical-replication-config-subscriber">
diff --git a/doc/src/sgml/ref/copy.sgml b/doc/src/sgml/ref/copy.sgml
index c25b52d0cb..87898b57f7 100644
--- a/doc/src/sgml/ref/copy.sgml
+++ b/doc/src/sgml/ref/copy.sgml
@@ -43,6 +43,7 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable
     FORCE_NOT_NULL ( <replaceable class="parameter">column_name</replaceable> [, ...] )
     FORCE_NULL ( <replaceable class="parameter">column_name</replaceable> [, ...] )
     ENCODING '<replaceable class="parameter">encoding_name</replaceable>'
+    PUBLICATION_NAMES ( <replaceable class="parameter">publication_name</replaceable> [, ...] )
 </synopsis>
  </refsynopsisdiv>
 
@@ -368,6 +369,41 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term><replaceable class="parameter">publication_name</replaceable></term>
+    <listitem>
+     <para>
+      The name of an
+      existing <link linkend="logical-replication-publication">publication</link>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><literal>PUBLICATION_NAMES</literal></term>
+    <listitem>
+     <para>
+      Specifies a list of publications. Only rows that match the
+      <link linkend="logical-replication-row-filter">row filter</link> of at
+      least one the publications are copied. If at least one publication in
+      the list has no row filter, the whole table contents will be copied.
+     </para>
+     <para>
+      If
+      the <link linkend="guc-publication-security">publication_security</link>
+      configuration parameter is <literal>on</literal>, the list is required.
+      and the user needs to have the <literal>USAGE</literal> privilege on all
+      the publications in the list which are actually used to retrieve the
+      data from given table.
+     </para>
+     <para>
+      This option is allowed only in <command>COPY TO</command>. Currently,
+      only the users with the <literal>REPLICATION</literal> privilege can use
+      this option.
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term><literal>WHERE</literal></term>
     <listitem>
diff --git a/doc/src/sgml/ref/grant.sgml b/doc/src/sgml/ref/grant.sgml
index 35bf0332c8..329a4f9023 100644
--- a/doc/src/sgml/ref/grant.sgml
+++ b/doc/src/sgml/ref/grant.sgml
@@ -82,6 +82,11 @@ GRANT { { SET | ALTER SYSTEM } [, ... ] | ALL [ PRIVILEGES ] }
     TO <replaceable class="parameter">role_specification</replaceable> [, ...] [ WITH GRANT OPTION ]
     [ GRANTED BY <replaceable class="parameter">role_specification</replaceable> ]
 
+GRANT { USAGE [, ... ] | ALL [ PRIVILEGES ] }
+    ON PUBLICATION <replaceable class="parameter">publication_name</replaceable> [, ...]
+    TO <replaceable class="parameter">role_specification</replaceable> [, ...] [ WITH GRANT OPTION ]
+    [ GRANTED BY <replaceable class="parameter">role_specification</replaceable> ]
+
 GRANT { { CREATE | USAGE } [, ...] | ALL [ PRIVILEGES ] }
     ON SCHEMA <replaceable>schema_name</replaceable> [, ...]
     TO <replaceable class="parameter">role_specification</replaceable> [, ...] [ WITH GRANT OPTION ]
@@ -513,8 +518,8 @@ GRANT admins TO joe;
    </para>
 
    <para>
-    Privileges on databases, tablespaces, schemas, languages, and
-    configuration parameters are
+    Privileges on databases, tablespaces, schemas, languages, configuration
+    parameters and publications are
     <productname>PostgreSQL</productname> extensions.
    </para>
  </refsect1>
diff --git a/src/backend/catalog/aclchk.c b/src/backend/catalog/aclchk.c
index c4232344aa..b7dc203859 100644
--- a/src/backend/catalog/aclchk.c
+++ b/src/backend/catalog/aclchk.c
@@ -253,6 +253,9 @@ restrict_and_check_grant(bool is_grant, AclMode avail_goptions, bool all_privs,
         case OBJECT_FUNCTION:
             whole_mask = ACL_ALL_RIGHTS_FUNCTION;
             break;
+        case OBJECT_PUBLICATION:
+            whole_mask = ACL_ALL_RIGHTS_PUBLICATION;
+            break;
         case OBJECT_LANGUAGE:
             whole_mask = ACL_ALL_RIGHTS_LANGUAGE;
             break;
@@ -485,6 +488,10 @@ ExecuteGrantStmt(GrantStmt *stmt)
             all_privileges = ACL_ALL_RIGHTS_FUNCTION;
             errormsg = gettext_noop("invalid privilege type %s for function");
             break;
+        case OBJECT_PUBLICATION:
+            all_privileges = ACL_ALL_RIGHTS_PUBLICATION;
+            errormsg = gettext_noop("invalid privilege type %s for publication");
+            break;
         case OBJECT_LANGUAGE:
             all_privileges = ACL_ALL_RIGHTS_LANGUAGE;
             errormsg = gettext_noop("invalid privilege type %s for language");
@@ -621,6 +628,9 @@ ExecGrantStmt_oids(InternalGrant *istmt)
         case OBJECT_LARGEOBJECT:
             ExecGrant_Largeobject(istmt);
             break;
+        case OBJECT_PUBLICATION:
+            ExecGrant_common(istmt, PublicationRelationId, ACL_ALL_RIGHTS_PUBLICATION, NULL);
+            break;
         case OBJECT_SCHEMA:
             ExecGrant_common(istmt, NamespaceRelationId, ACL_ALL_RIGHTS_SCHEMA, NULL);
             break;
@@ -731,6 +741,16 @@ objectNamesToOids(ObjectType objtype, List *objnames, bool is_grant)
                 objects = lappend_oid(objects, lobjOid);
             }
             break;
+        case OBJECT_PUBLICATION:
+            foreach(cell, objnames)
+            {
+                char       *nspname = strVal(lfirst(cell));
+                Oid            oid;
+
+                oid = get_publication_oid(nspname, false);
+                objects = lappend_oid(objects, oid);
+            }
+            break;
         case OBJECT_SCHEMA:
             foreach(cell, objnames)
             {
@@ -3023,6 +3043,8 @@ pg_aclmask(ObjectType objtype, Oid object_oid, AttrNumber attnum, Oid roleid,
             return object_aclmask(DatabaseRelationId, object_oid, roleid, mask, how);
         case OBJECT_FUNCTION:
             return object_aclmask(ProcedureRelationId, object_oid, roleid, mask, how);
+        case OBJECT_PUBLICATION:
+            return object_aclmask(PublicationRelationId, object_oid, roleid, mask, how);
         case OBJECT_LANGUAGE:
             return object_aclmask(LanguageRelationId, object_oid, roleid, mask, how);
         case OBJECT_LARGEOBJECT:
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index 14e57adee2..d76b052059 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -2936,7 +2936,6 @@ Oid
 LookupExplicitNamespace(const char *nspname, bool missing_ok)
 {
     Oid            namespaceId;
-    AclResult    aclresult;
 
     /* check for pg_temp alias */
     if (strcmp(nspname, "pg_temp") == 0)
@@ -2955,10 +2954,20 @@ LookupExplicitNamespace(const char *nspname, bool missing_ok)
     if (missing_ok && !OidIsValid(namespaceId))
         return InvalidOid;
 
-    aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(), ACL_USAGE);
-    if (aclresult != ACLCHECK_OK)
-        aclcheck_error(aclresult, OBJECT_SCHEMA,
-                       nspname);
+    /*
+     * If the publication security is active, bypass the standard security
+     * checks.
+     */
+    if (!publication_security)
+    {
+        AclResult    aclresult;
+
+        aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(),
+                                    ACL_USAGE);
+        if (aclresult != ACLCHECK_OK)
+            aclcheck_error(aclresult, OBJECT_SCHEMA,
+                           nspname);
+    }
     /* Schema search hook for this lookup */
     InvokeNamespaceSearchHook(namespaceId, true);
 
@@ -3835,10 +3844,16 @@ recomputeNamespacePath(void)
                 rname = NameStr(((Form_pg_authid) GETSTRUCT(tuple))->rolname);
                 namespaceId = get_namespace_oid(rname, true);
                 ReleaseSysCache(tuple);
+
+                /*
+                 * If the publication security is active, bypass the standard
+                 * security checks.
+                 */
                 if (OidIsValid(namespaceId) &&
                     !list_member_oid(oidlist, namespaceId) &&
-                    object_aclcheck(NamespaceRelationId, namespaceId, roleid,
-                                          ACL_USAGE) == ACLCHECK_OK &&
+                    (publication_security ||
+                     object_aclcheck(NamespaceRelationId, namespaceId, roleid,
+                                     ACL_USAGE) == ACLCHECK_OK) &&
                     InvokeNamespaceSearchHook(namespaceId, false))
                     oidlist = lappend_oid(oidlist, namespaceId);
             }
@@ -3865,8 +3880,9 @@ recomputeNamespacePath(void)
             namespaceId = get_namespace_oid(curname, true);
             if (OidIsValid(namespaceId) &&
                 !list_member_oid(oidlist, namespaceId) &&
-                object_aclcheck(NamespaceRelationId, namespaceId, roleid,
-                                      ACL_USAGE) == ACLCHECK_OK &&
+                (publication_security ||
+                 object_aclcheck(NamespaceRelationId, namespaceId, roleid,
+                                 ACL_USAGE) == ACLCHECK_OK) &&
                 InvokeNamespaceSearchHook(namespaceId, false))
                 oidlist = lappend_oid(oidlist, namespaceId);
         }
diff --git a/src/backend/catalog/objectaddress.c b/src/backend/catalog/objectaddress.c
index 25c50d66fd..ce482a88a9 100644
--- a/src/backend/catalog/objectaddress.c
+++ b/src/backend/catalog/objectaddress.c
@@ -587,7 +587,7 @@ static const ObjectPropertyType ObjectProperty[] =
         Anum_pg_publication_pubname,
         InvalidAttrNumber,
         Anum_pg_publication_pubowner,
-        InvalidAttrNumber,
+        Anum_pg_publication_pubacl,
         OBJECT_PUBLICATION,
         true
     },
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7f6024b7a5..93793b1fa4 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -1069,9 +1069,11 @@ GetPublicationRelationMapping(Oid pubid, Oid relid,
         *qual_isnull = true;
     }
 }
+
 /*
  * Pick those publications from a list which should actually be used to
- * publish given relation and return them.
+ * publish given relation, check their USAGE privilege is needed and return
+ * them.
  *
  * If publish_as_relid_p is passed, the relation whose tuple descriptor should
  * be used to publish the data is stored in *publish_as_relid_p.
@@ -1165,6 +1167,22 @@ GetEffectiveRelationPublications(Oid relid, List *publications,
                 publish = true;
         }
 
+        /*
+         * Check privileges before we use any information of the
+         * publication.
+         */
+        if (publication_security && publish)
+        {
+            Oid            roleid = GetUserId();
+            AclResult    aclresult;
+
+            aclresult = object_aclcheck(PublicationRelationId, pub->oid,
+                                        roleid, ACL_USAGE);
+            if (aclresult != ACLCHECK_OK)
+                aclcheck_error(aclresult, OBJECT_PUBLICATION,
+                               get_publication_name(pub->oid, false));
+        }
+
         /*
          * If the relation is to be published, determine actions to publish,
          * and list of columns, if appropriate.
diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c
index 153eae0379..f47ecd760b 100644
--- a/src/backend/commands/copy.c
+++ b/src/backend/commands/copy.c
@@ -41,6 +41,8 @@
 #include "utils/rel.h"
 #include "utils/rls.h"
 
+static bool isReplicationUser(void);
+
 /*
  *     DoCopy executes the SQL COPY statement
  *
@@ -71,6 +73,7 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
     Oid            relid;
     RawStmt    *query = NULL;
     Node       *whereClause = NULL;
+    List        *publication_names = NIL;
 
     /*
      * Disallow COPY to/from file or program except to users with the
@@ -105,14 +108,23 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
         }
     }
 
+    /*
+     * It seems more useful to tell the user immediately that something is
+     * wrong about the use of the PUBLICATION_NAMES option than to complain
+     * about missing SELECT privilege below: whoever is authorized to use this
+     * option shouldn't need the SELECT privilege at all. Therefore check the
+     * PUBLICATION_NAMES option earlier than the other options.  XXX Shouldn't
+     * we check all the options here anyway?
+     */
+    publication_names = ProcessCopyToPublicationOptions(pstate,
+                                                        stmt->options,
+                                                        stmt->is_from);
+
     if (stmt->relation)
     {
         LOCKMODE    lockmode = is_from ? RowExclusiveLock : AccessShareLock;
         ParseNamespaceItem *nsitem;
         RTEPermissionInfo *perminfo;
-        TupleDesc    tupDesc;
-        List       *attnums;
-        ListCell   *cur;
 
         Assert(!stmt->query);
 
@@ -127,6 +139,14 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
         perminfo = nsitem->p_perminfo;
         perminfo->requiredPerms = (is_from ? ACL_INSERT : ACL_SELECT);
 
+        /*
+         * The access by a replication user is controlled by the publication
+         * privileges, ACL_SELECT is not required. The actual checks of the
+         * publication privileges will take place later.
+         */
+        if (!is_from && publication_security)
+            perminfo->requiredPerms &= ~ACL_SELECT;
+
         if (stmt->whereClause)
         {
             /* add nsitem to query namespace */
@@ -147,19 +167,31 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
             whereClause = (Node *) make_ands_implicit((Expr *) whereClause);
         }
 
-        tupDesc = RelationGetDescr(rel);
-        attnums = CopyGetAttnums(tupDesc, rel, stmt->attlist);
-        foreach(cur, attnums)
+        /*
+         * If publication row filters need to be applied, the query form of
+         * COPY TO is used, so the permissions will be checked by the
+         * executor. Otherwise check the permissions now.
+         */
+        if (publication_names == NIL)
         {
-            int            attno;
-            Bitmapset **bms;
+            TupleDesc    tupDesc;
+            List       *attnums;
+            ListCell   *cur;
 
-            attno = lfirst_int(cur) - FirstLowInvalidHeapAttributeNumber;
-            bms = is_from ? &perminfo->insertedCols : &perminfo->selectedCols;
+            tupDesc = RelationGetDescr(rel);
+            attnums = CopyGetAttnums(tupDesc, rel, stmt->attlist);
+            foreach(cur, attnums)
+            {
+                int            attno;
+                Bitmapset **bms;
+
+                attno = lfirst_int(cur) - FirstLowInvalidHeapAttributeNumber;
+                bms = is_from ? &perminfo->insertedCols : &perminfo->selectedCols;
 
-            *bms = bms_add_member(*bms, attno);
+                *bms = bms_add_member(*bms, attno);
+            }
+            ExecCheckPermissions(pstate->p_rtable, list_make1(perminfo), true);
         }
-        ExecCheckPermissions(pstate->p_rtable, list_make1(perminfo), true);
 
         /*
          * Permission check for row security policies.
@@ -183,7 +215,7 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
                          errmsg("COPY FROM not supported with row-level security"),
                          errhint("Use INSERT statements instead.")));
 
-            query = CreateCopyToQuery(stmt, rel, stmt_location, stmt_len);
+            query = CreateCopyToQuery(stmt, rel, stmt_location, stmt_len, true);
 
             /*
              * Close the relation for now, but keep the lock on it to prevent
@@ -233,10 +265,24 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
     else
     {
         CopyToState cstate;
+        Relation    rel_loc = rel;
+
+        /*
+         * If publication row filters need to be applied, use the "COPY query
+         * TO ..."  form of the command.
+         */
+        if (rel && publication_names)
+        {
+            query = CreateCopyToQuery(stmt, rel, stmt_location, stmt_len, false);
+
+            /* BeginCopyTo() should only receive the query.  */
+            rel_loc = NULL;
+        }
 
-        cstate = BeginCopyTo(pstate, rel, query, relid,
+        cstate = BeginCopyTo(pstate, rel_loc, query, relid,
                              stmt->filename, stmt->is_program,
-                             NULL, stmt->attlist, stmt->options);
+                             NULL, stmt->attlist, stmt->options,
+                             publication_names);
         *processed = DoCopyTo(cstate);    /* copy from database to file */
         EndCopyTo(cstate);
     }
@@ -477,6 +523,13 @@ ProcessCopyOptions(ParseState *pstate,
                                 defel->defname),
                          parser_errposition(pstate, defel->location)));
         }
+        else if (strcmp(defel->defname, "publication_names") == 0)
+        {
+            /*
+             * ProcessCopyToPublicationOptions() should have been checked this
+             * already.
+             */
+        }
         else
             ereport(ERROR,
                     (errcode(ERRCODE_SYNTAX_ERROR),
@@ -629,6 +682,78 @@ ProcessCopyOptions(ParseState *pstate,
                  errmsg("CSV quote character must not appear in the NULL specification")));
 }
 
+/*
+ * Check the PUBLICATION_NAMES option of the "COPY TO" command.
+ *
+ * This option is checked separate from others.
+ */
+List *
+ProcessCopyToPublicationOptions(ParseState *pstate, List *options,
+                                bool is_from)
+{
+    ListCell   *option;
+    bool    found = false;
+    List    *result = NIL;
+
+    /* Extract options from the statement node tree */
+    foreach(option, options)
+    {
+        DefElem    *defel = lfirst_node(DefElem, option);
+
+        if (strcmp(defel->defname, "publication_names") == 0)
+        {
+            if (is_from)
+                ereport(ERROR,
+                        errmsg("PUBLICATION_NAMES option only available using COPY TO"));
+
+            if (result)
+                errorConflictingDefElem(defel, pstate);
+            found = true;
+            if (defel->arg == NULL || IsA(defel->arg, List))
+                result = castNode(List, defel->arg);
+            else
+                ereport(ERROR,
+                        (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+                         errmsg("argument to option \"%s\" must be a list of publication names",
+                                defel->defname),
+                         parser_errposition(pstate, defel->location)));
+        }
+    }
+
+    /*
+     * If the publication security is enabled, subscriber must send the list
+     * of publication in order to tell which subset of the data it is
+     * authorized to receive.
+     *
+     * publication_security does not affect sessions of non-replication users.
+     */
+    if (!found && publication_security && isReplicationUser())
+    {
+        /*
+         * This probably means that an old version of subscriber tries to get
+         * data from a secured publisher.
+         */
+        ereport(ERROR,
+                (errmsg("publication security requires the PUBLICATION_NAMES option")));
+    }
+
+    /*
+     * The option does only make sense in the context of (logical)
+     * replication. We could allow it for non-replication users too, but then
+     * we'd have to require it publication_security is on like above and thus
+     * break existing client code.
+     */
+    if (found && !isReplicationUser())
+        ereport(ERROR,
+                (errmsg("PUBLICATION_NAMES may only be used by roles with the REPLICATION privilege")));
+
+    if (found && result == NIL)
+        ereport(ERROR,
+                (errmsg("the value of the PUBLICATION_NAMES option must not be empty")));
+
+    return result;
+}
+
 /*
  * CopyGetAttnums - build an integer list of attnums to be copied
  *
@@ -719,3 +844,17 @@ CopyGetAttnums(TupleDesc tupDesc, Relation rel, List *attnamelist)
 
     return attnums;
 }
+
+/*
+ * Check whether the current session can use the USAGE privilege on
+ * publications instead of the SELECT privileges on tables.
+ *
+ * Superuser makes the test pass too so that subscriptions which connect to
+ * the publisher as superuser work fine.
+ */
+static bool
+isReplicationUser(void)
+{
+    return has_rolreplication(GetUserId()) || superuser();
+
+}
diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c
index ad79a56f75..79964cfd85 100644
--- a/src/backend/commands/copyto.c
+++ b/src/backend/commands/copyto.c
@@ -34,13 +34,18 @@
 #include "miscadmin.h"
 #include "nodes/makefuncs.h"
 #include "optimizer/optimizer.h"
+#include "parser/parsetree.h"
+#include "parser/parse_relation.h"
 #include "pgstat.h"
 #include "rewrite/rewriteHandler.h"
+#include "rewrite/rewriteManip.h"
 #include "storage/fd.h"
 #include "tcop/tcopprot.h"
+#include "utils/builtins.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/partcache.h"
+#include "utils/acl.h"
 #include "utils/rel.h"
 #include "utils/snapmgr.h"
 
@@ -132,6 +137,10 @@ static void CopySendEndOfRow(CopyToState cstate);
 static void CopySendInt32(CopyToState cstate, int32 val);
 static void CopySendInt16(CopyToState cstate, int16 val);
 
+static void AddPublicationFiltersToQuery(CopyToState cstate, Query *query,
+                                         List *publication_names);
+static Node *GetPublicationFilters(Relation rel, List *publications,
+                                   int varno);
 
 /*
  * Send copy start/stop messages for frontend copies.  These have changed
@@ -342,10 +351,13 @@ EndCopy(CopyToState cstate)
 
 /*
  * Turn "COPY table_name TO" form into "COPY (query) TO".
+ *
+ * TODO If it appears that the query for RLS also should have inh=true, remove
+ * the argument.
  */
 RawStmt *
 CreateCopyToQuery(const CopyStmt *stmt, Relation rel, int stmt_location,
-                  int stmt_len)
+                  int stmt_len, bool inh)
 {
     SelectStmt *select;
     ColumnRef  *cr;
@@ -413,6 +425,7 @@ CreateCopyToQuery(const CopyStmt *stmt, Relation rel, int stmt_location,
     from = makeRangeVar(get_namespace_name(RelationGetNamespace(rel)),
                         pstrdup(RelationGetRelationName(rel)),
                         -1);
+    from->inh = inh;
 
     /* Build query */
     select = makeNode(SelectStmt);
@@ -438,6 +451,7 @@ CreateCopyToQuery(const CopyStmt *stmt, Relation rel, int stmt_location,
  * 'data_dest_cb': Callback that processes the output data
  * 'attnamelist': List of char *, columns to include. NIL selects all cols.
  * 'options': List of DefElem. See copy_opt_item in gram.y for selections.
+ * 'publication_names': PUBLICATION_NAMES option (also contained in 'options')
  *
  * Returns a CopyToState, to be passed to DoCopyTo() and related functions.
  */
@@ -450,7 +464,8 @@ BeginCopyTo(ParseState *pstate,
             bool is_program,
             copy_data_dest_cb data_dest_cb,
             List *attnamelist,
-            List *options)
+            List *options,
+            List *publication_names)
 {
     CopyToState cstate;
     bool        pipe = (filename == NULL && data_dest_cb == NULL);
@@ -605,6 +620,12 @@ BeginCopyTo(ParseState *pstate,
                      errmsg("COPY query must have a RETURNING clause")));
         }
 
+        /*
+         * If the subscriber passed the publication names, use them.
+         */
+        if (publication_names)
+            AddPublicationFiltersToQuery(cstate, query, publication_names);
+
         /* plan the query */
         plan = pg_plan_query(query, pstate->p_sourcetext,
                              CURSOR_OPT_PARALLEL_OK, NULL);
@@ -1375,3 +1396,202 @@ CreateCopyDestReceiver(void)
 
     return (DestReceiver *) self;
 }
+
+/*
+ * For each table in the query add the row filters of the related publication
+ * to the WHERE clause. While doing so, check if the current user has the
+ * USAGE privilege on the publications.
+ */
+static void
+AddPublicationFiltersToQuery(CopyToState cstate, Query *query,
+                             List *publication_names)
+{
+    List    *publications = NIL;
+    Index rtindex;
+    FromExpr   *from_expr;
+    ListCell    *lc;
+
+    Assert(publication_names);
+
+    /* Convert the list of names to a list of OIDs. */
+    foreach(lc, publication_names)
+    {
+        char    *pubname = strVal(lfirst(lc));
+        Oid        pubid;
+        Publication    *pub;
+
+        pubid = get_publication_oid(pubname, true);
+        if (pubid == InvalidOid)
+        {
+            ereport(WARNING,
+                (errcode(ERRCODE_UNDEFINED_OBJECT),
+                 errmsg("publication \"%s\" does not exist", pubname)));
+            continue;
+        }
+
+        pub = GetPublication(pubid);
+
+        publications = lappend(publications, pub);
+    }
+
+    if (publications == NIL)
+        ereport(ERROR, errmsg("no valid publication received"));
+
+    /*
+     * If the query references at least one table, construct or adjust the
+     * WHERE clause according to the publications.
+     */
+    from_expr = query->jointree;
+
+    rtindex = 1;
+    foreach(lc, query->rtable)
+    {
+        RangeTblEntry *rte;
+        Relation    qrel;
+        List    *pubs_matched;
+        Node    *quals;
+
+        rte = lfirst_node(RangeTblEntry, lc);
+
+        /*
+         * NoLock because the relation should already be locked due to the
+         * prior rewriting.
+         */
+        qrel = relation_open(rte->relid, NoLock);
+
+        /*
+         * Clear ACL_SELECT on each RTE entry if the ACL_USAGE permission on
+         * publications should control the access, see below.
+         */
+        if (publication_security)
+        {
+            RTEPermissionInfo *perminfo;
+
+            perminfo = getRTEPermissionInfo(query->rteperminfos, rte);
+            perminfo->requiredPerms &= ~ACL_SELECT;
+        }
+
+        /*
+         * Retrieve the publications relevant to this relation, and if needed,
+         * check if the current user has the USAGE privilege on them.
+         */
+        pubs_matched = GetEffectiveRelationPublications(RelationGetRelid(qrel),
+                                                        publications, NULL, NULL);
+        if (pubs_matched == NIL)
+            ereport(ERROR,
+                    (errmsg("no publication for relation \"%s\"",
+                            get_rel_name(RelationGetRelid(qrel)))));
+
+        /* Range table implies there should be a FROM list. */
+        Assert(from_expr && from_expr->fromlist);
+
+        /*
+         * Use the publication filters to construct the (additional) filter
+         * expression for this relation.
+         */
+        quals = GetPublicationFilters(qrel, pubs_matched, rtindex);
+        if (quals)
+        {
+            if (from_expr->quals == NULL)
+            {
+                /* Assign a new WHERE clause to the query. */
+                from_expr->quals = quals;
+            }
+            else
+            {
+                List    *new_quals;
+
+                /*
+                 * AND the filter for this relation to the existing WHERE
+                 * clause.
+                 */
+                new_quals = list_make2(quals, from_expr->quals);
+                from_expr->quals = (Node *) make_andclause(new_quals);
+            }
+        }
+
+        list_free(pubs_matched);
+        relation_close(qrel, NoLock);
+        rtindex++;
+    }
+}
+
+/*
+ * Construct WHERE clause for a relation according to the given list of
+ * publications.
+ *
+ * Return NULL if at least one of the publications has no filter.
+ */
+static Node *
+GetPublicationFilters(Relation rel, List *publications, int varno)
+{
+    Oid        relid = RelationGetRelid(rel);
+    List       *filters = NIL;
+    Node       *result = NULL;
+    ListCell   *lc;
+    bool        isvarlena;
+    FmgrInfo    fmgrinfo;
+    Oid            outfunc;
+
+    Assert(publications);
+
+    /* Make sure we're ready call the output function for the node values. */
+    getTypeOutputInfo(PG_NODE_TREEOID, &outfunc, &isvarlena);
+    Assert(isvarlena);
+    fmgr_info(outfunc, &fmgrinfo);
+
+    /* Retrieve the publication filters. */
+    foreach(lc, publications)
+    {
+        Publication        *pub = (Publication *) lfirst(lc);
+        Datum    attrs, qual;
+        bool    attrs_isnull, qual_isnull;
+        char       *nodeStr;
+        Node       *node;
+
+        /* Get the filter expression. */
+        GetPublicationRelationMapping(pub->oid, relid, &attrs, &attrs_isnull,
+                                      &qual, &qual_isnull);
+
+        /*
+         * A single publication w/o expression means that the whole table
+         * should be published.
+         */
+        if (qual_isnull)
+        {
+            if (filters)
+            {
+                list_free_deep(filters);
+                filters = NIL;
+            }
+
+            break;
+        }
+
+        /* Get the filter expression and add it to the list. */
+        nodeStr = OutputFunctionCall(&fmgrinfo, qual);
+        node = stringToNode(nodeStr);
+        pfree(nodeStr);
+
+        /*
+         * Adjust varno so that the expression references the correct
+         * range table entry.
+         */
+        ChangeVarNodes(node, 1, varno, 0);
+
+        /*
+         * XXX Is it worth checking for duplicate expressions in the list?
+         */
+        filters = lappend(filters, node);
+    }
+
+    if (filters)
+    {
+        if (list_length(filters) > 1)
+            result = (Node *) make_orclause(filters);
+        else
+            result = (Node *) linitial(filters);
+    }
+
+    return result;
+}
diff --git a/src/backend/commands/publicationcmds.c b/src/backend/commands/publicationcmds.c
index f4ba572697..d9652604c7 100644
--- a/src/backend/commands/publicationcmds.c
+++ b/src/backend/commands/publicationcmds.c
@@ -800,6 +800,8 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
         BoolGetDatum(pubactions.pubtruncate);
     values[Anum_pg_publication_pubviaroot - 1] =
         BoolGetDatum(publish_via_partition_root);
+    values[Anum_pg_publication_pubowner - 1] = ObjectIdGetDatum(GetUserId());
+    nulls[Anum_pg_publication_pubacl - 1] = true;
 
     tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
 
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index a5115b9c1f..9ae47fa6e3 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -613,7 +613,9 @@ ExecCheckOneRelPerms(RTEPermissionInfo *perminfo)
     Oid            relOid = perminfo->relid;
 
     requiredPerms = perminfo->requiredPerms;
-    Assert(requiredPerms != 0);
+
+    if (requiredPerms == 0)
+        return true;
 
     /*
      * userid to check as: current user unless we have a setuid indication.
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index a0138382a1..fc70aa2057 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -7655,6 +7655,14 @@ privilege_target:
                     n->objs = $2;
                     $$ = n;
                 }
+            | PUBLICATION name_list
+                {
+                    PrivTarget *n = (PrivTarget *) palloc(sizeof(PrivTarget));
+                    n->targtype = ACL_TARGET_OBJECT;
+                    n->objtype = OBJECT_PUBLICATION;
+                    n->objs = $2;
+                    $$ = n;
+                }
             | SCHEMA name_list
                 {
                     PrivTarget *n = (PrivTarget *) palloc(sizeof(PrivTarget));
diff --git a/src/backend/replication/logical/tablesync.c b/src/backend/replication/logical/tablesync.c
index 07eea504ba..2a8cfc5be0 100644
--- a/src/backend/replication/logical/tablesync.c
+++ b/src/backend/replication/logical/tablesync.c
@@ -753,6 +753,27 @@ copy_read_data(void *outbuf, int minread, int maxread)
 }
 
 
+/*
+ * Return a comma-separated list of publications associated with the current
+ * subscriptions.
+ */
+static char *
+get_publication_names(void)
+{
+    StringInfoData buf;
+    ListCell   *lc;
+
+    initStringInfo(&buf);
+    foreach(lc, MySubscription->publications)
+    {
+        if (foreach_current_index(lc) > 0)
+            appendStringInfoString(&buf, ", ");
+        appendStringInfoString(&buf, quote_literal_cstr(strVal(lfirst(lc))));
+    }
+
+    return buf.data;
+}
+
 /*
  * Get information about remote relation in similar fashion the RELATION
  * message provides during replication. This function also returns the relation
@@ -770,7 +791,6 @@ fetch_remote_table_info(char *nspname, char *relname,
     Oid            qualRow[] = {TEXTOID};
     bool        isnull;
     int            natt;
-    ListCell   *lc;
     Bitmapset  *included_cols = NULL;
 
     lrel->nspname = nspname;
@@ -812,7 +832,6 @@ fetch_remote_table_info(char *nspname, char *relname,
     ExecDropSingleTupleTableSlot(slot);
     walrcv_clear_result(res);
 
-
     /*
      * Get column lists for each relation.
      *
@@ -824,15 +843,7 @@ fetch_remote_table_info(char *nspname, char *relname,
         WalRcvExecResult *pubres;
         TupleTableSlot *tslot;
         Oid            attrsRow[] = {INT2VECTOROID};
-        StringInfoData pub_names;
-
-        initStringInfo(&pub_names);
-        foreach(lc, MySubscription->publications)
-        {
-            if (foreach_current_index(lc) > 0)
-                appendStringInfoString(&pub_names, ", ");
-            appendStringInfoString(&pub_names, quote_literal_cstr(strVal(lfirst(lc))));
-        }
+        char       *pub_names = get_publication_names();
 
         /*
          * Fetch info about column lists for the relation (from all the
@@ -849,7 +860,7 @@ fetch_remote_table_info(char *nspname, char *relname,
                          " WHERE gpt.relid = %u AND c.oid = gpt.relid"
                          "   AND p.pubname IN ( %s )",
                          lrel->remoteid,
-                         pub_names.data);
+                         pub_names);
 
         pubres = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data,
                              lengthof(attrsRow), attrsRow);
@@ -904,8 +915,7 @@ fetch_remote_table_info(char *nspname, char *relname,
         ExecDropSingleTupleTableSlot(tslot);
 
         walrcv_clear_result(pubres);
-
-        pfree(pub_names.data);
+        pfree(pub_names);
     }
 
     /*
@@ -986,6 +996,18 @@ fetch_remote_table_info(char *nspname, char *relname,
 
     walrcv_clear_result(res);
 
+    lrel->pubnames = NULL;
+    if (walrcv_server_version(LogRepWorkerWalRcvConn) >= 160000)
+    {
+        /*
+         * If the publication ACL is implemented, the publisher is responsible
+         * for checking. All we need to do is to pass the publication names.
+         * The publisher should only return the data matching these
+         * publications and only check the ACLs of these.
+         */
+        lrel->pubnames = get_publication_names();
+    }
+
     /*
      * Get relation's row filter expressions. DISTINCT avoids the same
      * expression of a table in multiple publications from being included
@@ -1005,21 +1027,9 @@ fetch_remote_table_info(char *nspname, char *relname,
      * 3) one of the subscribed publications is declared as TABLES IN SCHEMA
      * that includes this relation
      */
-    if (walrcv_server_version(LogRepWorkerWalRcvConn) >= 150000)
+    else if (walrcv_server_version(LogRepWorkerWalRcvConn) >= 150000)
     {
-        StringInfoData pub_names;
-
-        /* Build the pubname list. */
-        initStringInfo(&pub_names);
-        foreach(lc, MySubscription->publications)
-        {
-            char       *pubname = strVal(lfirst(lc));
-
-            if (foreach_current_index(lc) > 0)
-                appendStringInfoString(&pub_names, ", ");
-
-            appendStringInfoString(&pub_names, quote_literal_cstr(pubname));
-        }
+        char       *pub_names = get_publication_names();
 
         /* Check for row filters. */
         resetStringInfo(&cmd);
@@ -1030,7 +1040,7 @@ fetch_remote_table_info(char *nspname, char *relname,
                          " WHERE gpt.relid = %u"
                          "   AND p.pubname IN ( %s )",
                          lrel->remoteid,
-                         pub_names.data);
+                         pub_names);
 
         res = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data, 1, qualRow);
 
@@ -1069,6 +1079,7 @@ fetch_remote_table_info(char *nspname, char *relname,
         ExecDropSingleTupleTableSlot(slot);
 
         walrcv_clear_result(res);
+        pfree(pub_names);
     }
 
     pfree(cmd.data);
@@ -1105,7 +1116,12 @@ copy_table(Relation rel)
     /* Start copy on the publisher. */
     initStringInfo(&cmd);
 
-    /* Regular table with no row filter */
+    /*
+     * Regular table with no row filter.
+     *
+     * Note that "qual" can also be NIL due to the fact the publisher is
+     * supposed to handle the row filters, so that we didn't check them here.
+     */
     if (lrel.relkind == RELKIND_RELATION && qual == NIL)
     {
         appendStringInfo(&cmd, "COPY %s (",
@@ -1122,8 +1138,6 @@ copy_table(Relation rel)
 
             appendStringInfoString(&cmd, quote_identifier(lrel.attnames[i]));
         }
-
-        appendStringInfoString(&cmd, ") TO STDOUT");
     }
     else
     {
@@ -1165,9 +1179,20 @@ copy_table(Relation rel)
             }
             list_free_deep(qual);
         }
+    }
+
+    appendStringInfoString(&cmd, ") TO STDOUT");
 
-        appendStringInfoString(&cmd, ") TO STDOUT");
+    if (lrel.pubnames)
+    {
+        /*
+         * Tell the publisher which publications we are interested in.
+         * Publishers of recent versions do need this information to construct
+         * the query filter and to check publication privileges.
+         */
+        appendStringInfo(&cmd, " (PUBLICATION_NAMES (%s)) ", lrel.pubnames);
     }
+
     res = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data, 0, NULL);
     pfree(cmd.data);
     if (res->status != WALRCV_OK_COPY_OUT)
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 6e0b51ada4..be3dc79585 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -14,6 +14,7 @@
 
 #include "access/tupconvert.h"
 #include "catalog/partition.h"
+#include "catalog/pg_authid.h"
 #include "catalog/pg_publication.h"
 #include "catalog/pg_publication_rel.h"
 #include "catalog/pg_subscription.h"
@@ -21,12 +22,14 @@
 #include "commands/subscriptioncmds.h"
 #include "executor/executor.h"
 #include "fmgr.h"
+#include "miscadmin.h"
 #include "nodes/makefuncs.h"
 #include "optimizer/optimizer.h"
 #include "replication/logical.h"
 #include "replication/logicalproto.h"
 #include "replication/origin.h"
 #include "replication/pgoutput.h"
+#include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
diff --git a/src/backend/replication/walsender.c b/src/backend/replication/walsender.c
index 4ed3747e3f..3da005d766 100644
--- a/src/backend/replication/walsender.c
+++ b/src/backend/replication/walsender.c
@@ -125,6 +125,12 @@ int            wal_sender_timeout = 60 * 1000; /* maximum time to send one WAL
                                              * data message */
 bool        log_replication_commands = false;
 
+/*
+ * Should USAGE privilege on publications be checked? Defaults to false so
+ * that server upgrade does not break existing logical replication.
+ */
+bool        publication_security = false;
+
 /*
  * State for WalSndWakeupRequest
  */
diff --git a/src/backend/utils/adt/acl.c b/src/backend/utils/adt/acl.c
index 8f7522d103..8c318676e1 100644
--- a/src/backend/utils/adt/acl.c
+++ b/src/backend/utils/adt/acl.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_namespace.h"
 #include "catalog/pg_parameter_acl.h"
 #include "catalog/pg_proc.h"
+#include "catalog/pg_publication.h"
 #include "catalog/pg_tablespace.h"
 #include "catalog/pg_type.h"
 #include "commands/dbcommands.h"
@@ -118,6 +119,7 @@ static AclMode convert_tablespace_priv_string(text *priv_type_text);
 static Oid    convert_type_name(text *typename);
 static AclMode convert_type_priv_string(text *priv_type_text);
 static AclMode convert_parameter_priv_string(text *priv_text);
+static AclMode convert_publication_priv_string(text *priv_type_text);
 static AclMode convert_role_priv_string(text *priv_type_text);
 static AclResult pg_role_aclcheck(Oid role_oid, Oid roleid, AclMode mode);
 
@@ -844,6 +846,10 @@ acldefault(ObjectType objtype, Oid ownerId)
             world_default = ACL_NO_RIGHTS;
             owner_default = ACL_ALL_RIGHTS_PARAMETER_ACL;
             break;
+        case OBJECT_PUBLICATION:
+            world_default = ACL_USAGE;
+            owner_default = ACL_ALL_RIGHTS_PUBLICATION;
+            break;
         default:
             elog(ERROR, "unrecognized object type: %d", (int) objtype);
             world_default = ACL_NO_RIGHTS;    /* keep compiler quiet */
@@ -929,6 +935,9 @@ acldefault_sql(PG_FUNCTION_ARGS)
         case 'p':
             objtype = OBJECT_PARAMETER_ACL;
             break;
+        case 'P':
+            objtype = OBJECT_PUBLICATION;
+            break;
         case 't':
             objtype = OBJECT_TABLESPACE;
             break;
@@ -4558,6 +4567,48 @@ convert_parameter_priv_string(text *priv_text)
     return convert_any_priv_string(priv_text, parameter_priv_map);
 }
 
+/*
+ * has_publication_privilege_id
+ *        Check user privileges on a publication given
+ *        publication oid and text priv name.
+ *        current_user is assumed
+ */
+Datum
+has_publication_privilege_id(PG_FUNCTION_ARGS)
+{
+    Oid            puboid = PG_GETARG_OID(0);
+    text       *priv_type_text = PG_GETARG_TEXT_PP(1);
+    Oid            roleid;
+    AclMode        mode;
+    AclResult    aclresult;
+
+    roleid = GetUserId();
+    mode = convert_publication_priv_string(priv_type_text);
+
+    if (!SearchSysCacheExists1(PUBLICATIONOID, ObjectIdGetDatum(puboid)))
+        PG_RETURN_NULL();
+
+    aclresult = object_aclcheck(PublicationRelationId, puboid, roleid, mode);
+
+    PG_RETURN_BOOL(aclresult == ACLCHECK_OK);
+}
+
+/*
+ * convert_publication_priv_string
+ *        Convert text string to AclMode value.
+ */
+static AclMode
+convert_publication_priv_string(text *priv_type_text)
+{
+    static const priv_map type_priv_map[] = {
+        {"USAGE", ACL_USAGE},
+        {"USAGE WITH GRANT OPTION", ACL_GRANT_OPTION_FOR(ACL_USAGE)},
+        {NULL, 0}
+    };
+
+    return convert_any_priv_string(priv_type_text, type_priv_map);
+}
+
 /*
  * pg_has_role variants
  *        These are all named "pg_has_role" at the SQL level.
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index c5a95f5dcc..ac928c83cc 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -685,6 +685,8 @@ const char *const config_group_names[] =
     gettext_noop("Replication / Primary Server"),
     /* REPLICATION_STANDBY */
     gettext_noop("Replication / Standby Servers"),
+    /* REPLICATION_PUBLISHERS */
+    gettext_noop("Replication / Publishers"),
     /* REPLICATION_SUBSCRIBERS */
     gettext_noop("Replication / Subscribers"),
     /* QUERY_TUNING_METHOD */
@@ -1972,6 +1974,16 @@ struct config_bool ConfigureNamesBool[] =
         NULL, NULL, NULL
     },
 
+    {
+        {"publication_security", PGC_SUSET, REPLICATION_PUBLISHERS,
+            gettext_noop("Enable publication security."),
+            gettext_noop("When enabled, the USAGE privilege is needed to access publications.")
+        },
+        &publication_security,
+        false,
+        NULL, NULL, NULL
+    },
+
     /* End-of-list marker */
     {
         {NULL, 0, 0, NULL, NULL}, NULL, false, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index d06074b86f..720b7157c2 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -353,6 +353,12 @@
                     # retrieve WAL after a failed attempt
 #recovery_min_apply_delay = 0        # minimum delay for applying changes during recovery
 
+# - Publishers -
+
+# These settings are ignored on a subscriber.
+
+#publication_security = off        # should publication privileges be checked?
+
 # - Subscribers -
 
 # These settings are ignored on a publisher.
diff --git a/src/bin/pg_dump/dumputils.c b/src/bin/pg_dump/dumputils.c
index 9753a6d868..7408353b72 100644
--- a/src/bin/pg_dump/dumputils.c
+++ b/src/bin/pg_dump/dumputils.c
@@ -511,6 +511,8 @@ do { \
         CONVERT_PRIV('r', "SELECT");
         CONVERT_PRIV('w', "UPDATE");
     }
+    else if (strcmp(type, "PUBLICATION") == 0)
+        CONVERT_PRIV('U', "USAGE");
     else
         abort();
 
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 527c7651ab..56db60c443 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -3950,6 +3950,8 @@ getPublications(Archive *fout, int *numPublications)
     int            i_pubdelete;
     int            i_pubtruncate;
     int            i_pubviaroot;
+    int            i_pubacl;
+    int            i_acldefault;
     int            i,
                 ntups;
 
@@ -3964,27 +3966,32 @@ getPublications(Archive *fout, int *numPublications)
     resetPQExpBuffer(query);
 
     /* Get the publications. */
-    if (fout->remoteVersion >= 130000)
+    if (fout->remoteVersion >= 150000)
         appendPQExpBufferStr(query,
-                             "SELECT p.tableoid, p.oid, p.pubname, "
-                             "p.pubowner, "
-                             "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, p.pubviaroot "
-                             "FROM pg_publication p");
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, p.pubviaroot,
p.pubacl,acldefault('P', p.pubowner) AS acldefault "
 
+                          "FROM pg_publication p");
+    else if (fout->remoteVersion >= 130000)
+        appendPQExpBuffer(query,
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, p.pubviaroot, '{}' AS
pubacl,'{}' AS acldefault "
 
+                          "FROM pg_publication p");
     else if (fout->remoteVersion >= 110000)
         appendPQExpBufferStr(query,
-                             "SELECT p.tableoid, p.oid, p.pubname, "
-                             "p.pubowner, "
-                             "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, false AS
pubviaroot"
 
-                             "FROM pg_publication p");
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, false AS pubviaroot,
'{}'AS pubacl, '{}' AS acldefault "
 
+                          "FROM pg_publication p");
     else
         appendPQExpBufferStr(query,
-                             "SELECT p.tableoid, p.oid, p.pubname, "
-                             "p.pubowner, "
-                             "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, false AS pubtruncate, false AS
pubviaroot"
 
-                             "FROM pg_publication p");
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, false AS pubtruncate, false AS
pubviaroot,'{}' AS pubacl, '{}' AS acldefault "
 
+                          "FROM pg_publication p");
 
     res = ExecuteSqlQuery(fout, query->data, PGRES_TUPLES_OK);
-
     ntups = PQntuples(res);
 
     i_tableoid = PQfnumber(res, "tableoid");
@@ -3997,6 +4004,8 @@ getPublications(Archive *fout, int *numPublications)
     i_pubdelete = PQfnumber(res, "pubdelete");
     i_pubtruncate = PQfnumber(res, "pubtruncate");
     i_pubviaroot = PQfnumber(res, "pubviaroot");
+    i_pubacl = PQfnumber(res, "pubacl");
+    i_acldefault = PQfnumber(res, "acldefault");
 
     pubinfo = pg_malloc(ntups * sizeof(PublicationInfo));
 
@@ -4021,6 +4030,11 @@ getPublications(Archive *fout, int *numPublications)
             (strcmp(PQgetvalue(res, i, i_pubtruncate), "t") == 0);
         pubinfo[i].pubviaroot =
             (strcmp(PQgetvalue(res, i, i_pubviaroot), "t") == 0);
+        pubinfo[i].dacl.acl = pg_strdup(PQgetvalue(res, i, i_pubacl));
+        pubinfo[i].dacl.acldefault = pg_strdup(PQgetvalue(res, i, i_acldefault));
+        pubinfo[i].dacl.privtype = 0;
+        pubinfo[i].dacl.initprivs = NULL;
+        pubinfo[i].dobj.components |= DUMP_COMPONENT_ACL;
 
         /* Decide whether we want to dump it */
         selectDumpableObject(&(pubinfo[i].dobj), fout);
@@ -4124,6 +4138,11 @@ dumpPublication(Archive *fout, const PublicationInfo *pubinfo)
                      NULL, pubinfo->rolname,
                      pubinfo->dobj.catId, 0, pubinfo->dobj.dumpId);
 
+    if (pubinfo->dobj.dump & DUMP_COMPONENT_ACL)
+        dumpACL(fout, pubinfo->dobj.dumpId, InvalidDumpId, "PUBLICATION",
+                pg_strdup(fmtId(pubinfo->dobj.name)), NULL, NULL,
+                pubinfo->rolname, &pubinfo->dacl);
+
     destroyPQExpBuffer(delq);
     destroyPQExpBuffer(query);
     free(qpubname);
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index e7cbd8d7ed..81770eefb2 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -613,6 +613,7 @@ typedef struct _policyInfo
 typedef struct _PublicationInfo
 {
     DumpableObject dobj;
+    DumpableAcl dacl;
     const char *rolname;
     bool        puballtables;
     bool        pubinsert;
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index c8a0bb7b3a..b37cd13a89 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6287,6 +6287,7 @@ describePublications(const char *pattern)
     PGresult   *res;
     bool        has_pubtruncate;
     bool        has_pubviaroot;
+    bool        has_pubacl;
 
     PQExpBufferData title;
     printTableContent cont;
@@ -6303,6 +6304,7 @@ describePublications(const char *pattern)
 
     has_pubtruncate = (pset.sversion >= 110000);
     has_pubviaroot = (pset.sversion >= 130000);
+    has_pubacl = (pset.sversion >= 160000);
 
     initPQExpBuffer(&buf);
 
@@ -6316,6 +6318,9 @@ describePublications(const char *pattern)
     if (has_pubviaroot)
         appendPQExpBufferStr(&buf,
                              ", pubviaroot");
+    if (has_pubacl)
+        appendPQExpBufferStr(&buf,
+                            ", pubacl");
     appendPQExpBufferStr(&buf,
                          "\nFROM pg_catalog.pg_publication\n");
 
@@ -6367,6 +6372,8 @@ describePublications(const char *pattern)
             ncols++;
         if (has_pubviaroot)
             ncols++;
+        if (has_pubacl)
+            ncols++;
 
         initPQExpBuffer(&title);
         printfPQExpBuffer(&title, _("Publication %s"), pubname);
@@ -6381,6 +6388,8 @@ describePublications(const char *pattern)
             printTableAddHeader(&cont, gettext_noop("Truncates"), true, align);
         if (has_pubviaroot)
             printTableAddHeader(&cont, gettext_noop("Via root"), true, align);
+        if (has_pubacl)
+            printTableAddHeader(&cont, gettext_noop("Access privileges"), true, align);
 
         printTableAddCell(&cont, PQgetvalue(res, i, 2), false, false);
         printTableAddCell(&cont, PQgetvalue(res, i, 3), false, false);
@@ -6391,6 +6400,8 @@ describePublications(const char *pattern)
             printTableAddCell(&cont, PQgetvalue(res, i, 7), false, false);
         if (has_pubviaroot)
             printTableAddCell(&cont, PQgetvalue(res, i, 8), false, false);
+        if (has_pubacl)
+            printTableAddCell(&cont, PQgetvalue(res, i, 9), false, false);
 
         if (!puballtables)
         {
diff --git a/src/bin/psql/tab-complete.c b/src/bin/psql/tab-complete.c
index 5e1882eaea..8455bd2ef0 100644
--- a/src/bin/psql/tab-complete.c
+++ b/src/bin/psql/tab-complete.c
@@ -3940,6 +3940,7 @@ psql_completion(const char *text, int start, int end)
                                             "LARGE OBJECT",
                                             "PARAMETER",
                                             "PROCEDURE",
+                                            "PUBLICATION",
                                             "ROUTINE",
                                             "SCHEMA",
                                             "SEQUENCE",
@@ -3977,6 +3978,8 @@ psql_completion(const char *text, int start, int end)
             COMPLETE_WITH_QUERY(Query_for_list_of_languages);
         else if (TailMatches("PROCEDURE"))
             COMPLETE_WITH_VERSIONED_SCHEMA_QUERY(Query_for_list_of_procedures);
+        else if (TailMatches("PUBLICATION"))
+            COMPLETE_WITH_VERSIONED_QUERY(Query_for_list_of_publications);
         else if (TailMatches("ROUTINE"))
             COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_routines);
         else if (TailMatches("SCHEMA"))
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index c0f2a8a77c..c617924c8e 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -7225,6 +7225,9 @@
 { oid => '2273', descr => 'current user privilege on schema by schema oid',
   proname => 'has_schema_privilege', provolatile => 's', prorettype => 'bool',
   proargtypes => 'oid text', prosrc => 'has_schema_privilege_id' },
+{ oid => '9800', descr => 'current user privilege on publication by publication oid',
+  proname => 'has_publication_privilege', provolatile => 's', prorettype => 'bool',
+  proargtypes => 'oid text', prosrc => 'has_publication_privilege_id' },
 
 { oid => '2390',
   descr => 'user privilege on tablespace by username, tablespace name',
diff --git a/src/include/catalog/pg_publication.h b/src/include/catalog/pg_publication.h
index dab5bc8444..87da458bdb 100644
--- a/src/include/catalog/pg_publication.h
+++ b/src/include/catalog/pg_publication.h
@@ -54,6 +54,12 @@ CATALOG(pg_publication,6104,PublicationRelationId)
 
     /* true if partition changes are published using root schema */
     bool        pubviaroot;
+
+#ifdef CATALOG_VARLEN            /* variable-length fields start here */
+    /* NOTE: These fields are not present in a relcache entry's rd_rel field. */
+    /* access permissions */
+    aclitem        pubacl[1] BKI_DEFAULT(_null_);
+#endif
 } FormData_pg_publication;
 
 /* ----------------
@@ -63,6 +69,8 @@ CATALOG(pg_publication,6104,PublicationRelationId)
  */
 typedef FormData_pg_publication *Form_pg_publication;
 
+DECLARE_TOAST(pg_publication, 9801, 9802);
+
 DECLARE_UNIQUE_INDEX_PKEY(pg_publication_oid_index, 6110, PublicationObjectIndexId, on pg_publication using btree(oid
oid_ops));
 DECLARE_UNIQUE_INDEX(pg_publication_pubname_index, 6111, PublicationNameIndexId, on pg_publication using btree(pubname
name_ops));
 
@@ -136,6 +144,8 @@ typedef enum PublicationPartOpt
     PUBLICATION_PART_ALL,
 } PublicationPartOpt;
 
+extern PGDLLIMPORT bool publication_security;
+
 extern List *GetPublicationRelations(Oid pubid, PublicationPartOpt pub_partopt);
 extern List *GetAllTablesPublications(void);
 extern List *GetAllTablesPublicationRelations(bool pubviaroot);
diff --git a/src/include/commands/copy.h b/src/include/commands/copy.h
index dd45dba465..55a6a262de 100644
--- a/src/include/commands/copy.h
+++ b/src/include/commands/copy.h
@@ -73,6 +73,8 @@ extern void DoCopy(ParseState *pstate, const CopyStmt *stmt,
                    uint64 *processed);
 
 extern void ProcessCopyOptions(ParseState *pstate, CopyFormatOptions *opts_out, bool is_from, List *options);
+extern List *ProcessCopyToPublicationOptions(ParseState *pstate,
+                                             List *options, bool is_from);
 extern CopyFromState BeginCopyFrom(ParseState *pstate, Relation rel, Node *whereClause,
                                    const char *filename,
                                    bool is_program, copy_data_source_cb data_source_cb, List *attnamelist, List
*options);
@@ -91,10 +93,11 @@ extern DestReceiver *CreateCopyDestReceiver(void);
  * internal prototypes
  */
 extern RawStmt *CreateCopyToQuery(const CopyStmt *stmt, Relation rel,
-                                  int stmt_location, int stmt_len);
+                                  int stmt_location, int stmt_len, bool inh);
 extern CopyToState BeginCopyTo(ParseState *pstate, Relation rel, RawStmt *raw_query,
                                Oid queryRelId, const char *filename, bool is_program,
-                               copy_data_dest_cb data_dest_cb, List *attnamelist, List *options);
+                               copy_data_dest_cb data_dest_cb, List *attnamelist, List *options,
+                               List *publication_names);
 extern void EndCopyTo(CopyToState cstate);
 extern uint64 DoCopyTo(CopyToState cstate);
 extern List *CopyGetAttnums(TupleDesc tupDesc, Relation rel,
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 3d67787e7a..1bedf03f71 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -88,8 +88,8 @@ typedef uint64 AclMode;            /* a bitmask of privilege bits */
 #define ACL_REFERENCES    (1<<5)
 #define ACL_TRIGGER        (1<<6)
 #define ACL_EXECUTE        (1<<7)    /* for functions */
-#define ACL_USAGE        (1<<8)    /* for languages, namespaces, FDWs, and
-                                 * servers */
+#define ACL_USAGE        (1<<8)    /* for languages, namespaces, FDWs, servers
+                                 * and publications */
 #define ACL_CREATE        (1<<9)    /* for namespaces and databases */
 #define ACL_CREATE_TEMP (1<<10) /* for databases */
 #define ACL_CONNECT        (1<<11) /* for databases */
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index 0ea2df5088..6d9b6fa250 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -113,6 +113,7 @@ typedef struct LogicalRepRelation
     char        replident;        /* replica identity */
     char        relkind;        /* remote relation kind */
     Bitmapset  *attkeys;        /* Bitmap of key columns */
+    char       *pubnames;        /* publication names (comma-separated list) */
 } LogicalRepRelation;
 
 /* Type mapping info */
diff --git a/src/include/utils/acl.h b/src/include/utils/acl.h
index f8e1238fa2..eb4e5044e8 100644
--- a/src/include/utils/acl.h
+++ b/src/include/utils/acl.h
@@ -169,6 +169,7 @@ typedef struct ArrayType Acl;
 #define ACL_ALL_RIGHTS_SCHEMA        (ACL_USAGE|ACL_CREATE)
 #define ACL_ALL_RIGHTS_TABLESPACE    (ACL_CREATE)
 #define ACL_ALL_RIGHTS_TYPE            (ACL_USAGE)
+#define ACL_ALL_RIGHTS_PUBLICATION    (ACL_USAGE)
 
 /* operation codes for pg_*_aclmask */
 typedef enum
diff --git a/src/include/utils/guc_tables.h b/src/include/utils/guc_tables.h
index d5a0880678..87ddeecc6e 100644
--- a/src/include/utils/guc_tables.h
+++ b/src/include/utils/guc_tables.h
@@ -75,6 +75,7 @@ enum config_group
     REPLICATION_SENDING,
     REPLICATION_PRIMARY,
     REPLICATION_STANDBY,
+    REPLICATION_PUBLISHERS,
     REPLICATION_SUBSCRIBERS,
     QUERY_TUNING_METHOD,
     QUERY_TUNING_COST,
diff --git a/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
b/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
index e65771067e..9178e102bb 100644
--- a/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
+++ b/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
@@ -38,7 +38,7 @@ test_copy_to_callback(PG_FUNCTION_ARGS)
     int64        processed;
 
     cstate = BeginCopyTo(NULL, rel, NULL, RelationGetRelid(rel), NULL, false,
-                         to_cb, NIL, NIL);
+                         to_cb, NIL, NIL, NIL);
     processed = DoCopyTo(cstate);
     EndCopyTo(cstate);
 
diff --git a/src/test/regress/expected/copy.out b/src/test/regress/expected/copy.out
index 8a8bf43fde..b6011bea0f 100644
--- a/src/test/regress/expected/copy.out
+++ b/src/test/regress/expected/copy.out
@@ -240,3 +240,55 @@ SELECT * FROM header_copytest ORDER BY a;
 (5 rows)
 
 drop table header_copytest;
+-- Filtering by publication
+-- Suppress the warning about insufficient wal_level when creating
+-- publications.
+set client_min_messages to error;
+create role regress_copy_repl_user login replication;
+create table published_copytest (i int);
+insert into published_copytest(i) select x from generate_series(1, 10) g(x);
+create publication pub1 for table published_copytest where (i >= 7);
+set publication_security to on;
+-- Test both table name and query forms of the COPY command.
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1));
+7
+8
+9
+10
+copy (select i from published_copytest) to stdout (publication_names (pub1));
+7
+8
+9
+10
+reset role;
+-- Publish some more data.
+create publication pub2 for table published_copytest where (i <= 2);
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1, pub2));
+1
+2
+7
+8
+9
+10
+reset role;
+-- If any publication has no filter, the other filters are ignored.
+create publication pub3 for table published_copytest;
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1, pub2, pub3));
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+reset role;
+reset publication_security;
+reset client_min_messages;
+drop role regress_copy_repl_user;
+drop publication pub1, pub2, pub3;
diff --git a/src/test/regress/expected/publication.out b/src/test/regress/expected/publication.out
index 427f87ea07..76a70c80d4 100644
--- a/src/test/regress/expected/publication.out
+++ b/src/test/regress/expected/publication.out
@@ -87,10 +87,10 @@ RESET client_min_messages;
 -- should be able to add schema to 'FOR TABLE' publication
 ALTER PUBLICATION testpub_fortable ADD TABLES IN SCHEMA pub_test;
 \dRp+ testpub_fortable
-                                Publication testpub_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                          Publication testpub_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_tbl1"
 Tables from schemas:
@@ -99,20 +99,20 @@ Tables from schemas:
 -- should be able to drop schema from 'FOR TABLE' publication
 ALTER PUBLICATION testpub_fortable DROP TABLES IN SCHEMA pub_test;
 \dRp+ testpub_fortable
-                                Publication testpub_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                          Publication testpub_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_tbl1"
 
 -- should be able to set schema to 'FOR TABLE' publication
 ALTER PUBLICATION testpub_fortable SET TABLES IN SCHEMA pub_test;
 \dRp+ testpub_fortable
-                                Publication testpub_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                          Publication testpub_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test"
 
@@ -123,10 +123,10 @@ CREATE PUBLICATION testpub_forschema FOR TABLES IN SCHEMA pub_test;
 CREATE PUBLICATION testpub_for_tbl_schema FOR TABLES IN SCHEMA pub_test, TABLE pub_test.testpub_nopk;
 RESET client_min_messages;
 \dRp+ testpub_for_tbl_schema
-                             Publication testpub_for_tbl_schema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                       Publication testpub_for_tbl_schema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test.testpub_nopk"
 Tables from schemas:
@@ -135,10 +135,10 @@ Tables from schemas:
 -- should be able to add a table of the same schema to the schema publication
 ALTER PUBLICATION testpub_forschema ADD TABLE pub_test.testpub_nopk;
 \dRp+ testpub_forschema
-                               Publication testpub_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test.testpub_nopk"
 Tables from schemas:
@@ -147,10 +147,10 @@ Tables from schemas:
 -- should be able to drop the table
 ALTER PUBLICATION testpub_forschema DROP TABLE pub_test.testpub_nopk;
 \dRp+ testpub_forschema
-                               Publication testpub_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test"
 
@@ -161,10 +161,10 @@ ERROR:  relation "testpub_nopk" is not part of the publication
 -- should be able to set table to schema publication
 ALTER PUBLICATION testpub_forschema SET TABLE pub_test.testpub_nopk;
 \dRp+ testpub_forschema
-                               Publication testpub_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test.testpub_nopk"
 
@@ -186,10 +186,10 @@ Publications:
     "testpub_foralltables"
 
 \dRp+ testpub_foralltables
-                              Publication testpub_foralltables
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | t          | t       | t       | f       | f         | f
+                                        Publication testpub_foralltables
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | t          | t       | t       | f       | f         | f        | 
 (1 row)
 
 DROP TABLE testpub_tbl2;
@@ -201,19 +201,19 @@ CREATE PUBLICATION testpub3 FOR TABLE testpub_tbl3;
 CREATE PUBLICATION testpub4 FOR TABLE ONLY testpub_tbl3;
 RESET client_min_messages;
 \dRp+ testpub3
-                                    Publication testpub3
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                              Publication testpub3
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_tbl3"
     "public.testpub_tbl3a"
 
 \dRp+ testpub4
-                                    Publication testpub4
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                              Publication testpub4
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        |
 Tables:
     "public.testpub_tbl3"
 
@@ -234,10 +234,10 @@ UPDATE testpub_parted1 SET a = 1;
 -- only parent is listed as being in publication, not the partition
 ALTER PUBLICATION testpub_forparted ADD TABLE testpub_parted;
 \dRp+ testpub_forparted
-                               Publication testpub_forparted
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub_forparted
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_parted"
 
@@ -252,10 +252,10 @@ ALTER TABLE testpub_parted DETACH PARTITION testpub_parted1;
 UPDATE testpub_parted1 SET a = 1;
 ALTER PUBLICATION testpub_forparted SET (publish_via_partition_root = true);
 \dRp+ testpub_forparted
-                               Publication testpub_forparted
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | t
+                                         Publication testpub_forparted
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | t        | 
 Tables:
     "public.testpub_parted"
 
@@ -284,10 +284,10 @@ SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub5 FOR TABLE testpub_rf_tbl1, testpub_rf_tbl2 WHERE (c <> 'test' AND d < 5) WITH (publish =
'insert');
 RESET client_min_messages;
 \dRp+ testpub5
-                                    Publication testpub5
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                              Publication testpub5
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "public.testpub_rf_tbl2" WHERE ((c <> 'test'::text) AND (d < 5))
@@ -300,10 +300,10 @@ Tables:
 
 ALTER PUBLICATION testpub5 ADD TABLE testpub_rf_tbl3 WHERE (e > 1000 AND e < 2000);
 \dRp+ testpub5
-                                    Publication testpub5
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                              Publication testpub5
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "public.testpub_rf_tbl2" WHERE ((c <> 'test'::text) AND (d < 5))
@@ -319,10 +319,10 @@ Publications:
 
 ALTER PUBLICATION testpub5 DROP TABLE testpub_rf_tbl2;
 \dRp+ testpub5
-                                    Publication testpub5
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                              Publication testpub5
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "public.testpub_rf_tbl3" WHERE ((e > 1000) AND (e < 2000))
@@ -330,10 +330,10 @@ Tables:
 -- remove testpub_rf_tbl1 and add testpub_rf_tbl3 again (another WHERE expression)
 ALTER PUBLICATION testpub5 SET TABLE testpub_rf_tbl3 WHERE (e > 300 AND e < 500);
 \dRp+ testpub5
-                                    Publication testpub5
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                              Publication testpub5
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl3" WHERE ((e > 300) AND (e < 500))
 
@@ -366,10 +366,10 @@ SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub_syntax1 FOR TABLE testpub_rf_tbl1, ONLY testpub_rf_tbl3 WHERE (e < 999) WITH (publish =
'insert');
 RESET client_min_messages;
 \dRp+ testpub_syntax1
-                                Publication testpub_syntax1
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                          Publication testpub_syntax1
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "public.testpub_rf_tbl3" WHERE (e < 999)
@@ -379,10 +379,10 @@ SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub_syntax2 FOR TABLE testpub_rf_tbl1, testpub_rf_schema1.testpub_rf_tbl5 WHERE (h < 999) WITH
(publish= 'insert');
 
 RESET client_min_messages;
 \dRp+ testpub_syntax2
-                                Publication testpub_syntax2
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                          Publication testpub_syntax2
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "testpub_rf_schema1.testpub_rf_tbl5" WHERE (h < 999)
@@ -497,10 +497,10 @@ CREATE PUBLICATION testpub6 FOR TABLES IN SCHEMA testpub_rf_schema2;
 ALTER PUBLICATION testpub6 SET TABLES IN SCHEMA testpub_rf_schema2, TABLE testpub_rf_schema2.testpub_rf_tbl6 WHERE (i
<99);
 
 RESET client_min_messages;
 \dRp+ testpub6
-                                    Publication testpub6
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                              Publication testpub6
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "testpub_rf_schema2.testpub_rf_tbl6" WHERE (i < 99)
 Tables from schemas:
@@ -714,10 +714,10 @@ CREATE PUBLICATION testpub_table_ins WITH (publish = 'insert, truncate');
 RESET client_min_messages;
 ALTER PUBLICATION testpub_table_ins ADD TABLE testpub_tbl5 (a);        -- ok
 \dRp+ testpub_table_ins
-                               Publication testpub_table_ins
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | t         | f
+                                         Publication testpub_table_ins
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | t         | f        | 
 Tables:
     "public.testpub_tbl5" (a)
 
@@ -891,10 +891,10 @@ CREATE TABLE testpub_tbl_both_filters (a int, b int, c int, PRIMARY KEY (a,c));
 ALTER TABLE testpub_tbl_both_filters REPLICA IDENTITY USING INDEX testpub_tbl_both_filters_pkey;
 ALTER PUBLICATION testpub_both_filters ADD TABLE testpub_tbl_both_filters (a,c) WHERE (c != 1);
 \dRp+ testpub_both_filters
-                              Publication testpub_both_filters
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                        Publication testpub_both_filters
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_tbl_both_filters" (a, c) WHERE (c <> 1)
 
@@ -1099,10 +1099,10 @@ ERROR:  relation "testpub_tbl1" is already member of publication "testpub_fortbl
 CREATE PUBLICATION testpub_fortbl FOR TABLE testpub_tbl1;
 ERROR:  publication "testpub_fortbl" already exists
 \dRp+ testpub_fortbl
-                                 Publication testpub_fortbl
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                           Publication testpub_fortbl
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test.testpub_nopk"
     "public.testpub_tbl1"
@@ -1140,10 +1140,10 @@ Publications:
     "testpub_fortbl"
 
 \dRp+ testpub_default
-                                Publication testpub_default
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | f         | f
+                                          Publication testpub_default
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | f         | f        | 
 Tables:
     "pub_test.testpub_nopk"
     "public.testpub_tbl1"
@@ -1214,17 +1214,57 @@ ALTER PUBLICATION testpub4 owner to regress_publication_user2; -- fail
 ERROR:  permission denied to change owner of publication "testpub4"
 HINT:  The owner of a FOR TABLES IN SCHEMA publication must be a superuser.
 ALTER PUBLICATION testpub4 owner to regress_publication_user; -- ok
+-- Test the USAGE privilege.
+SET ROLE regress_publication_user;
+CREATE ROLE regress_publication_user4;
+-- First, check that USAGE is granted to PUBLIC by default.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+ has_publication_privilege 
+---------------------------
+ t
+(1 row)
+
+-- Revoke the USAGE privilege from PUBLIC.
+SET ROLE regress_publication_user;
+REVOKE USAGE ON PUBLICATION testpub4 FROM public;
+-- regress_publication_user4 does not have the privilege now.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+ has_publication_privilege 
+---------------------------
+ f
+(1 row)
+
+-- Grant USAGE to regress_publication_user4 explicitly.
+SET ROLE regress_publication_user;
+GRANT USAGE ON PUBLICATION testpub4 TO regress_publication_user4;
+-- regress_publication_user4 does have the privilege now.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+ has_publication_privilege 
+---------------------------
+ t
+(1 row)
+
 SET ROLE regress_publication_user;
 DROP PUBLICATION testpub4;
 DROP ROLE regress_publication_user3;
+DROP ROLE regress_publication_user4;
 REVOKE CREATE ON DATABASE regression FROM regress_publication_user2;
 DROP TABLE testpub_parted;
 DROP TABLE testpub_tbl1;
 \dRp+ testpub_default
-                                Publication testpub_default
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | f         | f
+                                          Publication testpub_default
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | f         | f        | 
 (1 row)
 
 -- fail - must be owner of publication
@@ -1263,19 +1303,19 @@ CREATE TABLE "CURRENT_SCHEMA"."CURRENT_SCHEMA"(id int);
 SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub1_forschema FOR TABLES IN SCHEMA pub_test1;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
 CREATE PUBLICATION testpub2_forschema FOR TABLES IN SCHEMA pub_test1, pub_test2, pub_test3;
 \dRp+ testpub2_forschema
-                               Publication testpub2_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub2_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1289,44 +1329,44 @@ CREATE PUBLICATION testpub6_forschema FOR TABLES IN SCHEMA "CURRENT_SCHEMA", CUR
 CREATE PUBLICATION testpub_fortable FOR TABLE "CURRENT_SCHEMA"."CURRENT_SCHEMA";
 RESET client_min_messages;
 \dRp+ testpub3_forschema
-                               Publication testpub3_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub3_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "public"
 
 \dRp+ testpub4_forschema
-                               Publication testpub4_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub4_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "CURRENT_SCHEMA"
 
 \dRp+ testpub5_forschema
-                               Publication testpub5_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub5_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "CURRENT_SCHEMA"
     "public"
 
 \dRp+ testpub6_forschema
-                               Publication testpub6_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub6_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "CURRENT_SCHEMA"
     "public"
 
 \dRp+ testpub_fortable
-                                Publication testpub_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                          Publication testpub_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "CURRENT_SCHEMA.CURRENT_SCHEMA"
 
@@ -1360,10 +1400,10 @@ ERROR:  schema "testpub_view" does not exist
 -- dropping the schema should reflect the change in publication
 DROP SCHEMA pub_test3;
 \dRp+ testpub2_forschema
-                               Publication testpub2_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub2_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1371,20 +1411,20 @@ Tables from schemas:
 -- renaming the schema should reflect the change in publication
 ALTER SCHEMA pub_test1 RENAME to pub_test1_renamed;
 \dRp+ testpub2_forschema
-                               Publication testpub2_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub2_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1_renamed"
     "pub_test2"
 
 ALTER SCHEMA pub_test1_renamed RENAME to pub_test1;
 \dRp+ testpub2_forschema
-                               Publication testpub2_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub2_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1392,10 +1432,10 @@ Tables from schemas:
 -- alter publication add schema
 ALTER PUBLICATION testpub1_forschema ADD TABLES IN SCHEMA pub_test2;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1404,10 +1444,10 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema ADD TABLES IN SCHEMA non_existent_schema;
 ERROR:  schema "non_existent_schema" does not exist
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1416,10 +1456,10 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema ADD TABLES IN SCHEMA pub_test1;
 ERROR:  schema "pub_test1" is already member of publication "testpub1_forschema"
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1427,10 +1467,10 @@ Tables from schemas:
 -- alter publication drop schema
 ALTER PUBLICATION testpub1_forschema DROP TABLES IN SCHEMA pub_test2;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
@@ -1438,10 +1478,10 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema DROP TABLES IN SCHEMA pub_test2;
 ERROR:  tables from schema "pub_test2" are not part of the publication
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
@@ -1449,29 +1489,29 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema DROP TABLES IN SCHEMA non_existent_schema;
 ERROR:  schema "non_existent_schema" does not exist
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
 -- drop all schemas
 ALTER PUBLICATION testpub1_forschema DROP TABLES IN SCHEMA pub_test1;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 (1 row)
 
 -- alter publication set multiple schema
 ALTER PUBLICATION testpub1_forschema SET TABLES IN SCHEMA pub_test1, pub_test2;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1480,10 +1520,10 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema SET TABLES IN SCHEMA non_existent_schema;
 ERROR:  schema "non_existent_schema" does not exist
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1492,10 +1532,10 @@ Tables from schemas:
 -- removing the duplicate schemas
 ALTER PUBLICATION testpub1_forschema SET TABLES IN SCHEMA pub_test1, pub_test1;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
@@ -1574,18 +1614,18 @@ SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub3_forschema;
 RESET client_min_messages;
 \dRp+ testpub3_forschema
-                               Publication testpub3_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub3_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 (1 row)
 
 ALTER PUBLICATION testpub3_forschema SET TABLES IN SCHEMA pub_test1;
 \dRp+ testpub3_forschema
-                               Publication testpub3_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub3_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
@@ -1595,20 +1635,20 @@ CREATE PUBLICATION testpub_forschema_fortable FOR TABLES IN SCHEMA pub_test1, TA
 CREATE PUBLICATION testpub_fortable_forschema FOR TABLE pub_test2.tbl1, TABLES IN SCHEMA pub_test1;
 RESET client_min_messages;
 \dRp+ testpub_forschema_fortable
-                           Publication testpub_forschema_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                     Publication testpub_forschema_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test2.tbl1"
 Tables from schemas:
     "pub_test1"
 
 \dRp+ testpub_fortable_forschema
-                           Publication testpub_fortable_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                     Publication testpub_fortable_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test2.tbl1"
 Tables from schemas:
diff --git a/src/test/regress/sql/copy.sql b/src/test/regress/sql/copy.sql
index f9da7b1508..4174823cff 100644
--- a/src/test/regress/sql/copy.sql
+++ b/src/test/regress/sql/copy.sql
@@ -268,3 +268,39 @@ a    c    b
 
 SELECT * FROM header_copytest ORDER BY a;
 drop table header_copytest;
+
+-- Filtering by publication
+
+-- Suppress the warning about insufficient wal_level when creating
+-- publications.
+set client_min_messages to error;
+
+create role regress_copy_repl_user login replication;
+create table published_copytest (i int);
+insert into published_copytest(i) select x from generate_series(1, 10) g(x);
+create publication pub1 for table published_copytest where (i >= 7);
+
+set publication_security to on;
+
+-- Test both table name and query forms of the COPY command.
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1));
+copy (select i from published_copytest) to stdout (publication_names (pub1));
+reset role;
+
+-- Publish some more data.
+create publication pub2 for table published_copytest where (i <= 2);
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1, pub2));
+reset role;
+
+-- If any publication has no filter, the other filters are ignored.
+create publication pub3 for table published_copytest;
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1, pub2, pub3));
+reset role;
+
+reset publication_security;
+reset client_min_messages;
+drop role regress_copy_repl_user;
+drop publication pub1, pub2, pub3;
diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql
index a47c5939d5..303870a1e9 100644
--- a/src/test/regress/sql/publication.sql
+++ b/src/test/regress/sql/publication.sql
@@ -808,9 +808,37 @@ SET ROLE regress_publication_user3;
 ALTER PUBLICATION testpub4 owner to regress_publication_user2; -- fail
 ALTER PUBLICATION testpub4 owner to regress_publication_user; -- ok
 
+-- Test the USAGE privilege.
+SET ROLE regress_publication_user;
+CREATE ROLE regress_publication_user4;
+-- First, check that USAGE is granted to PUBLIC by default.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+
+-- Revoke the USAGE privilege from PUBLIC.
+SET ROLE regress_publication_user;
+REVOKE USAGE ON PUBLICATION testpub4 FROM public;
+-- regress_publication_user4 does not have the privilege now.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+
+-- Grant USAGE to regress_publication_user4 explicitly.
+SET ROLE regress_publication_user;
+GRANT USAGE ON PUBLICATION testpub4 TO regress_publication_user4;
+-- regress_publication_user4 does have the privilege now.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+
 SET ROLE regress_publication_user;
 DROP PUBLICATION testpub4;
 DROP ROLE regress_publication_user3;
+DROP ROLE regress_publication_user4;
 
 REVOKE CREATE ON DATABASE regression FROM regress_publication_user2;
 
diff --git a/src/test/subscription/t/027_nosuperuser.pl b/src/test/subscription/t/027_nosuperuser.pl
index 59192dbe2f..31e94514c1 100644
--- a/src/test/subscription/t/027_nosuperuser.pl
+++ b/src/test/subscription/t/027_nosuperuser.pl
@@ -7,8 +7,10 @@ use warnings;
 use PostgreSQL::Test::Cluster;
 use Test::More;
 
-my ($node_publisher, $node_subscriber, $publisher_connstr, $result, $offset);
+my ($node_publisher, $node_subscriber, $publisher_connstr, $result, $offset,
+    $offset_pub);
 $offset = 0;
+$offset_pub = 0;
 
 sub publish_insert
 {
@@ -103,7 +105,8 @@ $node_publisher->init(allows_streaming => 'logical');
 $node_subscriber->init;
 $node_publisher->start;
 $node_subscriber->start;
-$publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+# Non-super user, so that we can test publication privileges.
+$publisher_connstr = $node_publisher->connstr . ' dbname=postgres user=regress_alice';
 my %remainder_a = (
     publisher  => 0,
     subscriber => 1);
@@ -141,6 +144,8 @@ for my $node ($node_publisher, $node_subscriber)
 }
 $node_publisher->safe_psql(
     'postgres', qq(
+ALTER ROLE regress_alice REPLICATION;
+
 SET SESSION AUTHORIZATION regress_alice;
 
 CREATE PUBLICATION alice
@@ -316,4 +321,53 @@ expect_replication("alice.unpartitioned", 2, 23, 25,
     "nosuperuser nobypassrls table owner can replicate delete into unpartitioned despite rls"
 );
 
+# Test publication permissions.
+$node_publisher->append_conf(
+    'postgresql.conf',
+    qq[
+publication_security = on
+]);
+$node_publisher->restart;
+
+# First, make sure that the user specified in the subscription is not able to
+# access the data, then do some changes. (By deleting everything we make the
+# following checks simpler.)
+$node_publisher->safe_psql(
+    'postgres', qq(
+REVOKE USAGE ON PUBLICATION alice FROM PUBLIC;
+REVOKE USAGE ON PUBLICATION alice FROM regress_alice;
+ALTER DATABASE postgres SET publication_security TO on;
+
+DELETE FROM alice.unpartitioned;
+));
+# Missing permission should cause error.
+expect_failure("alice.unpartitioned", 2, 23, 25,
+               qr/ERROR: ( [A-Z0-9]+:)? permission denied for publication alice/msi, 0);
+# Check that the missing privilege makes table synchronization fail too.
+$node_subscriber->safe_psql(
+    'postgres', qq(
+SET SESSION AUTHORIZATION regress_admin;
+DROP SUBSCRIPTION admin_sub;
+TRUNCATE TABLE alice.unpartitioned;
+CREATE SUBSCRIPTION admin_sub CONNECTION '$publisher_connstr' PUBLICATION alice;
+));
+# Note that expect_failure() does not wait for the end of the synchronization,
+# so if there was any data on publisher side and if it found its way to the
+# subscriber, the function might still see an empty table. So we only rely on
+# the function to check the error message.
+expect_failure("alice.unpartitioned", 0, '', '',
+               qr/ERROR: ( [A-Z0-9]+:)? permission denied for publication alice/msi, 0);
+# Restore the privilege on the publication.
+$node_publisher->safe_psql(
+    'postgres', qq(
+GRANT USAGE ON PUBLICATION alice TO regress_alice;
+));
+# Wait for synchronization to complete.
+$node_subscriber->wait_for_subscription_sync;
+# The replication should work again now.
+publish_insert("alice.unpartitioned", 1);
+expect_replication("alice.unpartitioned", 1, 1, 1,
+   "unpartitioned is replicated as soon as regress_alic has permissions on alice publication"
+);
+
 done_testing();
-- 
2.31.1


Re: Privileges on PUBLICATION

From
"Gregory Stark (as CFM)"
Date:
FYI this looks like it needs a rebase due to a conflict in copy.c and
an offset in pgoutput.c.

Is there anything specific that still needs review or do you think
you've handled all Peter's concerns? In particular, is there "a
comprehensive description of what it is trying to do"? :)



Re: Privileges on PUBLICATION

From
Antonin Houska
Date:
Gregory Stark (as CFM) <stark.cfm@gmail.com> wrote:

> FYI this looks like it needs a rebase due to a conflict in copy.c and
> an offset in pgoutput.c.
> 
> Is there anything specific that still needs review or do you think
> you've handled all Peter's concerns? In particular, is there "a
> comprehensive description of what it is trying to do"? :)

I tried to improve the documentation and commit messages in v05. v06 (just
rebased) is attached.

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com

From d4490664ec80f52d23c4345eec5771764bcdbb17 Mon Sep 17 00:00:00 2001
From: Antonin Houska <ah@cybertec.at>
Date: Wed, 15 Mar 2023 04:21:01 +0100
Subject: [PATCH 1/2] Move some code into functions.

This is only a preparation for a patch that introduces USAGE privilege on
publications. It should make the following diff a little bit easier to read.
---
 src/backend/catalog/pg_publication.c        | 236 +++++++++++++++++---
 src/backend/commands/copy.c                 |  81 +------
 src/backend/commands/copyto.c               |  89 ++++++++
 src/backend/replication/pgoutput/pgoutput.c | 139 +-----------
 src/include/catalog/pg_publication.h        |   6 +
 src/include/commands/copy.h                 |   2 +
 6 files changed, 308 insertions(+), 245 deletions(-)

diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index a98fcad421..7f6024b7a5 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -1025,6 +1025,208 @@ GetPublicationByName(const char *pubname, bool missing_ok)
     return OidIsValid(oid) ? GetPublication(oid) : NULL;
 }
 
+/*
+ * Get the mapping for given publication and relation.
+ */
+void
+GetPublicationRelationMapping(Oid pubid, Oid relid,
+                              Datum *attrs, bool *attrs_isnull,
+                              Datum *qual, bool *qual_isnull)
+{
+    Publication *publication;
+    HeapTuple    pubtuple = NULL;
+    Oid            schemaid = get_rel_namespace(relid);
+
+    publication = GetPublication(pubid);
+
+    /*
+     * We don't consider row filters or column lists for FOR ALL TABLES or
+     * FOR TABLES IN SCHEMA publications.
+     */
+    if (!publication->alltables &&
+        !SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
+                               ObjectIdGetDatum(schemaid),
+                               ObjectIdGetDatum(publication->oid)))
+        pubtuple = SearchSysCacheCopy2(PUBLICATIONRELMAP,
+                                       ObjectIdGetDatum(relid),
+                                       ObjectIdGetDatum(publication->oid));
+
+    if (HeapTupleIsValid(pubtuple))
+    {
+        /* Lookup the column list attribute. */
+        *attrs = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
+                                 Anum_pg_publication_rel_prattrs,
+                                 attrs_isnull);
+
+        /* Null indicates no filter. */
+        *qual = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
+                                Anum_pg_publication_rel_prqual,
+                                qual_isnull);
+    }
+    else
+    {
+        *attrs_isnull = true;
+        *qual_isnull = true;
+    }
+}
+/*
+ * Pick those publications from a list which should actually be used to
+ * publish given relation and return them.
+ *
+ * If publish_as_relid_p is passed, the relation whose tuple descriptor should
+ * be used to publish the data is stored in *publish_as_relid_p.
+ *
+ * If pubactions is passed, update the structure according to the matching
+ * publications.
+ */
+List *
+GetEffectiveRelationPublications(Oid relid, List *publications,
+                                 Oid *publish_as_relid_p,
+                                 PublicationActions *pubactions)
+{
+    Oid            schemaId = get_rel_namespace(relid);
+    List       *pubids = GetRelationPublications(relid);
+    /*
+     * We don't acquire a lock on the namespace system table as we build the
+     * cache entry using a historic snapshot and all the later changes are
+     * absorbed while decoding WAL.
+     */
+    List       *schemaPubids = GetSchemaPublications(schemaId);
+    ListCell   *lc;
+    Oid            publish_as_relid = relid;
+    int            publish_ancestor_level = 0;
+    bool        am_partition = get_rel_relispartition(relid);
+    char        relkind = get_rel_relkind(relid);
+    List       *rel_publications = NIL;
+
+    foreach(lc, publications)
+    {
+        Publication *pub = lfirst(lc);
+        bool        publish = false;
+
+        /*
+         * Under what relid should we publish changes in this publication?
+         * We'll use the top-most relid across all publications. Also track
+         * the ancestor level for this publication.
+         */
+        Oid    pub_relid = relid;
+        int    ancestor_level = 0;
+
+        /*
+         * If this is a FOR ALL TABLES publication, pick the partition root
+         * and set the ancestor level accordingly.
+         */
+        if (pub->alltables)
+        {
+            publish = true;
+            if (pub->pubviaroot && am_partition)
+            {
+                List       *ancestors = get_partition_ancestors(relid);
+
+                pub_relid = llast_oid(ancestors);
+                ancestor_level = list_length(ancestors);
+            }
+        }
+
+        if (!publish)
+        {
+            bool        ancestor_published = false;
+
+            /*
+             * For a partition, check if any of the ancestors are published.
+             * If so, note down the topmost ancestor that is published via
+             * this publication, which will be used as the relation via which
+             * to publish the partition's changes.
+             */
+            if (am_partition)
+            {
+                Oid            ancestor;
+                int            level;
+                List       *ancestors = get_partition_ancestors(relid);
+
+                ancestor = GetTopMostAncestorInPublication(pub->oid,
+                                                           ancestors,
+                                                           &level);
+
+                if (ancestor != InvalidOid)
+                {
+                    ancestor_published = true;
+                    if (pub->pubviaroot)
+                    {
+                        pub_relid = ancestor;
+                        ancestor_level = level;
+                    }
+                }
+            }
+
+            if (list_member_oid(pubids, pub->oid) ||
+                list_member_oid(schemaPubids, pub->oid) ||
+                ancestor_published)
+                publish = true;
+        }
+
+        /*
+         * If the relation is to be published, determine actions to publish,
+         * and list of columns, if appropriate.
+         *
+         * Don't publish changes for partitioned tables, because publishing
+         * those of its partitions suffices, unless partition changes won't be
+         * published due to pubviaroot being set.
+         */
+        if (publish &&
+            (relkind != RELKIND_PARTITIONED_TABLE || pub->pubviaroot))
+        {
+            if (pubactions)
+            {
+                pubactions->pubinsert |= pub->pubactions.pubinsert;
+                pubactions->pubupdate |= pub->pubactions.pubupdate;
+                pubactions->pubdelete |= pub->pubactions.pubdelete;
+                pubactions->pubtruncate |= pub->pubactions.pubtruncate;
+            }
+
+            /*
+             * We want to publish the changes as the top-most ancestor across
+             * all publications. So we need to check if the already calculated
+             * level is higher than the new one. If yes, we can ignore the new
+             * value (as it's a child). Otherwise the new value is an
+             * ancestor, so we keep it.
+             */
+            if (publish_ancestor_level > ancestor_level)
+                continue;
+
+            /*
+             * If we found an ancestor higher up in the tree, discard the list
+             * of publications through which we replicate it, and use the new
+             * ancestor.
+             */
+            if (publish_ancestor_level < ancestor_level)
+            {
+                publish_as_relid = pub_relid;
+                publish_ancestor_level = ancestor_level;
+
+                /* reset the publication list for this relation */
+                rel_publications = NIL;
+            }
+            else
+            {
+                /* Same ancestor level, has to be the same OID. */
+                Assert(publish_as_relid == pub_relid);
+            }
+
+            /* Track publications for this ancestor. */
+            rel_publications = lappend(rel_publications, pub);
+        }
+    }
+
+    list_free(pubids);
+    list_free(schemaPubids);
+
+    if (publish_as_relid_p)
+        *publish_as_relid_p = publish_as_relid;
+
+    return rel_publications;
+}
+
 /*
  * Returns information of tables in a publication.
  */
@@ -1108,10 +1310,8 @@ pg_get_publication_tables(PG_FUNCTION_ARGS)
 
     if (funcctx->call_cntr < list_length(tables))
     {
-        HeapTuple    pubtuple = NULL;
         HeapTuple    rettuple;
         Oid            relid = list_nth_oid(tables, funcctx->call_cntr);
-        Oid            schemaid = get_rel_namespace(relid);
         Datum        values[NUM_PUBLICATION_TABLES_ELEM] = {0};
         bool        nulls[NUM_PUBLICATION_TABLES_ELEM] = {0};
 
@@ -1123,35 +1323,9 @@ pg_get_publication_tables(PG_FUNCTION_ARGS)
 
         values[0] = ObjectIdGetDatum(relid);
 
-        /*
-         * We don't consider row filters or column lists for FOR ALL TABLES or
-         * FOR TABLES IN SCHEMA publications.
-         */
-        if (!publication->alltables &&
-            !SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
-                                   ObjectIdGetDatum(schemaid),
-                                   ObjectIdGetDatum(publication->oid)))
-            pubtuple = SearchSysCacheCopy2(PUBLICATIONRELMAP,
-                                           ObjectIdGetDatum(relid),
-                                           ObjectIdGetDatum(publication->oid));
-
-        if (HeapTupleIsValid(pubtuple))
-        {
-            /* Lookup the column list attribute. */
-            values[1] = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
-                                        Anum_pg_publication_rel_prattrs,
-                                        &(nulls[1]));
-
-            /* Null indicates no filter. */
-            values[2] = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
-                                        Anum_pg_publication_rel_prqual,
-                                        &(nulls[2]));
-        }
-        else
-        {
-            nulls[1] = true;
-            nulls[2] = true;
-        }
+        GetPublicationRelationMapping(publication->oid, relid,
+                                      &values[1], &nulls[1],
+                                      &values[2], &nulls[2]);
 
         /* Show all columns when the column list is not specified. */
         if (nulls[1] == true)
diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c
index 167d31a2d9..8edc2c19f6 100644
--- a/src/backend/commands/copy.c
+++ b/src/backend/commands/copy.c
@@ -177,92 +177,13 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
          */
         if (check_enable_rls(relid, InvalidOid, false) == RLS_ENABLED)
         {
-            SelectStmt *select;
-            ColumnRef  *cr;
-            ResTarget  *target;
-            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
-             *
-             * If no columns are specified in the attribute list of the COPY
-             * command, then the target list is 'all' columns. Therefore, '*'
-             * should be used as the target list for the resulting SELECT
-             * statement.
-             *
-             * In the case that columns are specified in the attribute list,
-             * create a ColumnRef and ResTarget for each column and add them
-             * to the target list for the resulting SELECT statement.
-             */
-            if (!stmt->attlist)
-            {
-                cr = makeNode(ColumnRef);
-                cr->fields = list_make1(makeNode(A_Star));
-                cr->location = -1;
-
-                target = makeNode(ResTarget);
-                target->name = NULL;
-                target->indirection = NIL;
-                target->val = (Node *) cr;
-                target->location = -1;
-
-                targetList = list_make1(target);
-            }
-            else
-            {
-                ListCell   *lc;
-
-                foreach(lc, stmt->attlist)
-                {
-                    /*
-                     * Build the ColumnRef for each column.  The ColumnRef
-                     * 'fields' property is a String node that corresponds to
-                     * the column name respectively.
-                     */
-                    cr = makeNode(ColumnRef);
-                    cr->fields = list_make1(lfirst(lc));
-                    cr->location = -1;
-
-                    /* Build the ResTarget and add the ColumnRef to it. */
-                    target = makeNode(ResTarget);
-                    target->name = NULL;
-                    target->indirection = NIL;
-                    target->val = (Node *) cr;
-                    target->location = -1;
-
-                    /* Add each column to the SELECT statement's target list */
-                    targetList = lappend(targetList, target);
-                }
-            }
-
-            /*
-             * Build RangeVar for from clause, fully qualified based on the
-             * relation which we have opened and locked.  Use "ONLY" so that
-             * COPY retrieves rows from only the target table not any
-             * inheritance children, the same as when RLS doesn't apply.
-             */
-            from = makeRangeVar(get_namespace_name(RelationGetNamespace(rel)),
-                                pstrdup(RelationGetRelationName(rel)),
-                                -1);
-            from->inh = false;    /* apply ONLY */
-
-            /* Build query */
-            select = makeNode(SelectStmt);
-            select->targetList = targetList;
-            select->fromClause = list_make1(from);
-
-            query = makeNode(RawStmt);
-            query->stmt = (Node *) select;
-            query->stmt_location = stmt_location;
-            query->stmt_len = stmt_len;
-
+            query = CreateCopyToQuery(stmt, rel, stmt_location, stmt_len);
             /*
              * Close the relation for now, but keep the lock on it to prevent
              * changes between now and when we start the query-based COPY.
diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c
index beea1ac687..af0cdef158 100644
--- a/src/backend/commands/copyto.c
+++ b/src/backend/commands/copyto.c
@@ -32,6 +32,7 @@
 #include "libpq/pqformat.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
 #include "optimizer/optimizer.h"
 #include "pgstat.h"
 #include "rewrite/rewriteHandler.h"
@@ -339,6 +340,94 @@ EndCopy(CopyToState cstate)
     pfree(cstate);
 }
 
+/*
+ * Turn "COPY table_name TO" form into "COPY (query) TO".
+ */
+RawStmt *
+CreateCopyToQuery(const CopyStmt *stmt, Relation rel, int stmt_location,
+                  int stmt_len)
+{
+    SelectStmt *select;
+    ColumnRef  *cr;
+    ResTarget  *target;
+    RangeVar   *from;
+    List       *targetList = NIL;
+    RawStmt    *query = NULL;
+
+    /*
+     * Build target list
+     *
+     * If no columns are specified in the attribute list of the COPY command,
+     * then the target list is 'all' columns. Therefore, '*' should be used as
+     * the target list for the resulting SELECT statement.
+     *
+     * In the case that columns are specified in the attribute list, create a
+     * ColumnRef and ResTarget for each column and add them to the target list
+     * for the resulting SELECT statement.
+     */
+    if (!stmt->attlist)
+    {
+        cr = makeNode(ColumnRef);
+        cr->fields = list_make1(makeNode(A_Star));
+        cr->location = -1;
+
+        target = makeNode(ResTarget);
+        target->name = NULL;
+        target->indirection = NIL;
+        target->val = (Node *) cr;
+        target->location = -1;
+
+        targetList = list_make1(target);
+    }
+    else
+    {
+        ListCell   *lc;
+
+        foreach(lc, stmt->attlist)
+        {
+            /*
+             * Build the ColumnRef for each column.  The ColumnRef 'fields'
+             * property is a String node that corresponds to the column name
+             * respectively.
+             */
+            cr = makeNode(ColumnRef);
+            cr->fields = list_make1(lfirst(lc));
+            cr->location = -1;
+
+            /* Build the ResTarget and add the ColumnRef to it. */
+            target = makeNode(ResTarget);
+            target->name = NULL;
+            target->indirection = NIL;
+            target->val = (Node *) cr;
+            target->location = -1;
+
+            /* Add each column to the SELECT statement's target list */
+            targetList = lappend(targetList, target);
+        }
+    }
+
+    /*
+     * Build RangeVar for from clause, fully qualified based on the relation
+     * which we have opened and locked.
+     */
+    from = makeRangeVar(get_namespace_name(RelationGetNamespace(rel)),
+                        pstrdup(RelationGetRelationName(rel)),
+                        -1);
+    from->inh = false;    /* apply ONLY */
+
+    /* Build query */
+    select = makeNode(SelectStmt);
+    select->targetList = targetList;
+    select->fromClause = list_make1(from);
+
+    query = makeNode(RawStmt);
+    query->stmt = (Node *) select;
+    query->stmt_location = stmt_location;
+    query->stmt_len = stmt_len;
+
+    return query;
+}
+
 /*
  * Setup CopyToState to read tuples from a table or a query for COPY TO.
  *
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 00a2d73dab..21b8b2944e 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -2063,21 +2063,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
     /* Validate the entry */
     if (!entry->replicate_valid)
     {
-        Oid            schemaId = get_rel_namespace(relid);
-        List       *pubids = GetRelationPublications(relid);
-
-        /*
-         * We don't acquire a lock on the namespace system table as we build
-         * the cache entry using a historic snapshot and all the later changes
-         * are absorbed while decoding WAL.
-         */
-        List       *schemaPubids = GetSchemaPublications(schemaId);
-        ListCell   *lc;
-        Oid            publish_as_relid = relid;
-        int            publish_ancestor_level = 0;
-        bool        am_partition = get_rel_relispartition(relid);
-        char        relkind = get_rel_relkind(relid);
-        List       *rel_publications = NIL;
+        List    *rel_publications;
 
         /* Reload publications if needed before use. */
         if (!publications_valid)
@@ -2140,123 +2126,10 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
          * but here we only need to consider ones that the subscriber
          * requested.
          */
-        foreach(lc, data->publications)
-        {
-            Publication *pub = lfirst(lc);
-            bool        publish = false;
-
-            /*
-             * Under what relid should we publish changes in this publication?
-             * We'll use the top-most relid across all publications. Also
-             * track the ancestor level for this publication.
-             */
-            Oid            pub_relid = relid;
-            int            ancestor_level = 0;
-
-            /*
-             * If this is a FOR ALL TABLES publication, pick the partition
-             * root and set the ancestor level accordingly.
-             */
-            if (pub->alltables)
-            {
-                publish = true;
-                if (pub->pubviaroot && am_partition)
-                {
-                    List       *ancestors = get_partition_ancestors(relid);
-
-                    pub_relid = llast_oid(ancestors);
-                    ancestor_level = list_length(ancestors);
-                }
-            }
-
-            if (!publish)
-            {
-                bool        ancestor_published = false;
-
-                /*
-                 * For a partition, check if any of the ancestors are
-                 * published.  If so, note down the topmost ancestor that is
-                 * published via this publication, which will be used as the
-                 * relation via which to publish the partition's changes.
-                 */
-                if (am_partition)
-                {
-                    Oid            ancestor;
-                    int            level;
-                    List       *ancestors = get_partition_ancestors(relid);
-
-                    ancestor = GetTopMostAncestorInPublication(pub->oid,
-                                                               ancestors,
-                                                               &level);
-
-                    if (ancestor != InvalidOid)
-                    {
-                        ancestor_published = true;
-                        if (pub->pubviaroot)
-                        {
-                            pub_relid = ancestor;
-                            ancestor_level = level;
-                        }
-                    }
-                }
-
-                if (list_member_oid(pubids, pub->oid) ||
-                    list_member_oid(schemaPubids, pub->oid) ||
-                    ancestor_published)
-                    publish = true;
-            }
-
-            /*
-             * If the relation is to be published, determine actions to
-             * publish, and list of columns, if appropriate.
-             *
-             * Don't publish changes for partitioned tables, because
-             * publishing those of its partitions suffices, unless partition
-             * changes won't be published due to pubviaroot being set.
-             */
-            if (publish &&
-                (relkind != RELKIND_PARTITIONED_TABLE || pub->pubviaroot))
-            {
-                entry->pubactions.pubinsert |= pub->pubactions.pubinsert;
-                entry->pubactions.pubupdate |= pub->pubactions.pubupdate;
-                entry->pubactions.pubdelete |= pub->pubactions.pubdelete;
-                entry->pubactions.pubtruncate |= pub->pubactions.pubtruncate;
-
-                /*
-                 * We want to publish the changes as the top-most ancestor
-                 * across all publications. So we need to check if the already
-                 * calculated level is higher than the new one. If yes, we can
-                 * ignore the new value (as it's a child). Otherwise the new
-                 * value is an ancestor, so we keep it.
-                 */
-                if (publish_ancestor_level > ancestor_level)
-                    continue;
-
-                /*
-                 * If we found an ancestor higher up in the tree, discard the
-                 * list of publications through which we replicate it, and use
-                 * the new ancestor.
-                 */
-                if (publish_ancestor_level < ancestor_level)
-                {
-                    publish_as_relid = pub_relid;
-                    publish_ancestor_level = ancestor_level;
-
-                    /* reset the publication list for this relation */
-                    rel_publications = NIL;
-                }
-                else
-                {
-                    /* Same ancestor level, has to be the same OID. */
-                    Assert(publish_as_relid == pub_relid);
-                }
-
-                /* Track publications for this ancestor. */
-                rel_publications = lappend(rel_publications, pub);
-            }
-        }
-
-        entry->publish_as_relid = publish_as_relid;
+        rel_publications = GetEffectiveRelationPublications(relid,
+                                                            data->publications,
+                                                            &entry->publish_as_relid,
+                                                            &entry->pubactions);
 
         /*
          * Initialize the tuple slot, map, and row filter. These are only used
@@ -2275,8 +2148,6 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
             pgoutput_column_list_init(data, rel_publications, entry);
         }
 
-        list_free(pubids);
-        list_free(schemaPubids);
         list_free(rel_publications);
 
         entry->replicate_valid = true;
diff --git a/src/include/catalog/pg_publication.h b/src/include/catalog/pg_publication.h
index 6ecaa2a01e..dab5bc8444 100644
--- a/src/include/catalog/pg_publication.h
+++ b/src/include/catalog/pg_publication.h
@@ -113,6 +113,12 @@ typedef struct PublicationRelInfo
 extern Publication *GetPublication(Oid pubid);
 extern Publication *GetPublicationByName(const char *pubname, bool missing_ok);
 extern List *GetRelationPublications(Oid relid);
+extern void GetPublicationRelationMapping(Oid pubid, Oid relid,
+                                          Datum *attrs, bool *attrs_isnull,
+                                          Datum *qual, bool *qual_isnull);
+extern List *GetEffectiveRelationPublications(Oid relid, List *publications,
+                                              Oid *publish_as_relid_p,
+                                              PublicationActions *pubactions);
 
 /*---------
  * Expected values for pub_partopt parameter of GetRelationPublications(),
diff --git a/src/include/commands/copy.h b/src/include/commands/copy.h
index 33175868f6..774b835251 100644
--- a/src/include/commands/copy.h
+++ b/src/include/commands/copy.h
@@ -92,6 +92,8 @@ extern DestReceiver *CreateCopyDestReceiver(void);
 /*
  * internal prototypes
  */
+extern RawStmt *CreateCopyToQuery(const CopyStmt *stmt, Relation rel,
+                                  int stmt_location, int stmt_len);
 extern CopyToState BeginCopyTo(ParseState *pstate, Relation rel, RawStmt *raw_query,
                                Oid queryRelId, const char *filename, bool is_program,
                                copy_data_dest_cb data_dest_cb, List *attnamelist, List *options);
-- 
2.31.1

From 281272f99527ab53547d4d6a6ce71d2d5bfe7b14 Mon Sep 17 00:00:00 2001
From: Antonin Houska <ah@cybertec.at>
Date: Wed, 15 Mar 2023 04:21:01 +0100
Subject: [PATCH 2/2] Implement the USAGE privilege on PUBLICATION.

Publication row filters and column lists can be used to prevent subsets of
data from being replicated via the logical replication system. These features
can address performance issues, but currently should not be used for security
purposes, such as hiding sensitive data from the subscribers. The problem is
that any subscriber can get the data from any publication, so if the sensitive
data is deliberately not published via one publication, it can still be
available via another one (supposedly created for another subscriber).

This patch adds an ACL column to the pg_publication catalog, implements the
corresponding checks and enhances the GRANT and REVOKE commands to grant and
revoke the USAGE privilege on publication to / from roles. The USAGE privilege
is initially granted to the PUBLIC group (so that existing configurations
don't get broken) but the user can revoke it and grant it only to individual
subscription users (i.e. users mentioned in the subscription connection
configuration). Thus the publisher instance can reject to send data that given
subscriber is not supposed to receive.

Obviously, the publication privileges are checked on the publisher side,
otherwise the implementation wouldn't be secure. The output plugin
(pgoutput.c) is the easy part because it already does receive the list of
publications whose data it should send to the subscriber. The initial table
synchronization is a little bit tricky because so far the "tablesync worker"
(running on the subscriber side) was responsible for constructing the SQL
query for the COPY TO command, which is executed on the publisher side.

This patch adds a new option PUBLICATION_NAMES to the COPY TO command. The
subscriber uses it to pass a list of publications to the publisher. The
publisher checks if the subscription user has the USAGE privilege on each
publication, retrieves the corresponding data (i.e. rows matching the row
filters of the publications) and sends it to the subscriber.

Since the publisher and subscriber instances can be on different major
versions of postgres, and since old subscribers cannot send the publication
names during the initial table synchronization, a new configuration variable
"publication_security" was added. The default value is "off", meaning that the
publisher does not require the COPY TO command to contain the
PUBLICATION_NAMES option. If the option is passed yet, the publisher does not
check the privileges on the listed publications, but it does perform row
filtering according to the publication filters. Thus upgrade of the publisher
instance does not break anything.

Once all the subscribers have migrated to the postgres version that supports
this feature, this variable should be set to "on". At that moment the
publisher starts to require the presence of the PUBLICATION_NAMES option in
the COPY TO command, as long as the COPY TO is executed by a role which has
the REPLICATION privilege. (Role w/o the REPLICATION privilege aren't
currently allowed to use the PUBLICATION_NAMES option.)
---
 doc/src/sgml/catalogs.sgml                    |   9 +
 doc/src/sgml/config.sgml                      |  28 ++
 doc/src/sgml/ddl.sgml                         |  14 +
 doc/src/sgml/logical-replication.sgml         |  72 +--
 doc/src/sgml/ref/copy.sgml                    |  36 ++
 doc/src/sgml/ref/grant.sgml                   |   9 +-
 src/backend/catalog/aclchk.c                  |  22 +
 src/backend/catalog/namespace.c               |  34 +-
 src/backend/catalog/objectaddress.c           |   2 +-
 src/backend/catalog/pg_publication.c          |  20 +-
 src/backend/commands/copy.c                   | 168 ++++++-
 src/backend/commands/copyto.c                 | 218 ++++++++-
 src/backend/commands/publicationcmds.c        |   2 +
 src/backend/executor/execMain.c               |   4 +-
 src/backend/parser/gram.y                     |   8 +
 src/backend/replication/logical/tablesync.c   |  91 ++--
 src/backend/replication/pgoutput/pgoutput.c   |   3 +
 src/backend/replication/walsender.c           |   6 +
 src/backend/utils/adt/acl.c                   |  51 +++
 src/backend/utils/misc/guc_tables.c           |  12 +
 src/backend/utils/misc/postgresql.conf.sample |   6 +
 src/bin/pg_dump/dumputils.c                   |   2 +
 src/bin/pg_dump/pg_dump.c                     |  47 +-
 src/bin/pg_dump/pg_dump.h                     |   1 +
 src/bin/psql/describe.c                       |  11 +
 src/bin/psql/tab-complete.c                   |   3 +
 src/include/catalog/pg_proc.dat               |   3 +
 src/include/catalog/pg_publication.h          |  10 +
 src/include/commands/copy.h                   |   5 +-
 src/include/replication/logicalproto.h        |   1 +
 src/include/utils/acl.h                       |   1 +
 src/include/utils/guc_tables.h                |   1 +
 .../test_copy_callbacks/test_copy_callbacks.c |   2 +-
 src/test/regress/expected/copy.out            |  52 +++
 src/test/regress/expected/publication.out     | 424 ++++++++++--------
 src/test/regress/sql/copy.sql                 |  36 ++
 src/test/regress/sql/publication.sql          |  28 ++
 src/test/subscription/t/027_nosuperuser.pl    |  58 ++-
 38 files changed, 1197 insertions(+), 303 deletions(-)

diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index 746baf5053..c5baafceef 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -6361,6 +6361,15 @@ SCRAM-SHA-256$<replaceable><iteration count></replaceable>:<replaceable>&l
        publication instead of its own.
       </para></entry>
      </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pubacl</structfield> <type>aclitem[]</type>
+      </para>
+      <para>
+       Access privileges; see <xref linkend="ddl-priv"/> for details
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index e5c41cc6c6..b9caae4423 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -4958,6 +4958,34 @@ ANY <replaceable class="parameter">num_sync</replaceable> ( <replaceable class="
      </variablelist>
     </sect2>
 
+    <sect2 id="runtime-config-replication-publisher">
+     <title>Publishers</title>
+
+     <para>
+      These settings control the behavior of a logical replication publisher.
+      Their values on the subscriber are irrelevant.
+     </para>
+
+     <variablelist>
+
+     <varlistentry id="guc-publication-security" xreflabel="publication_security">
+      <term><varname>publication_security</varname> (<type>boolean</type>)
+       <indexterm>
+        <primary><varname>publication_security</varname> configuration parameter</primary>
+        <secondary>in a publisher</secondary>
+       </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Specifies whether the publisher should check the publication
+        privileges before it sends data to the subscriber.  See
+        <xref linkend="logical-replication-security"/> for more details.
+       </para>
+      </listitem>
+     </varlistentry>
+     </variablelist>
+    </sect2>
+
     <sect2 id="runtime-config-replication-subscriber">
      <title>Subscribers</title>
 
diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index 5179125510..9a71790678 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -1963,6 +1963,13 @@ REVOKE ALL ON accounts FROM PUBLIC;
        statements that have previously performed this lookup, so this is not
        a completely secure way to prevent object access.
       </para>
+      <para>
+       For publications, allows logical replication via particular
+       publication. The user specified in
+       the <link linkend="sql-createsubscription"><command>CREATE
+       SUBSCRIPTION</command></link> command must have this privilege on all
+       publications listed in that command.
+      </para>
       <para>
        For sequences, allows use of the
        <function>currval</function> and <function>nextval</function> functions.
@@ -2156,6 +2163,7 @@ REVOKE ALL ON accounts FROM PUBLIC;
        <literal>FOREIGN DATA WRAPPER</literal>,
        <literal>FOREIGN SERVER</literal>,
        <literal>LANGUAGE</literal>,
+       <literal>PUBLICATION</literal>,
        <literal>SCHEMA</literal>,
        <literal>SEQUENCE</literal>,
        <literal>TYPE</literal>
@@ -2252,6 +2260,12 @@ REVOKE ALL ON accounts FROM PUBLIC;
       <entry>none</entry>
       <entry><literal>\dconfig+</literal></entry>
      </row>
+     <row>
+      <entry><literal>PUBLICATION</literal></entry>
+      <entry><literal>U</literal></entry>
+      <entry>U</entry>
+      <entry><literal>\dRp+</literal></entry>
+     </row>
      <row>
       <entry><literal>SCHEMA</literal></entry>
       <entry><literal>UC</literal></entry>
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 1bd5660c87..64774e68cd 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -898,26 +898,26 @@ CREATE PUBLICATION
     <command>psql</command> can be used to show the row filter expressions (if
     defined) for each publication.
 <programlisting>
-test_pub=# \dRp+
+  test_pub=# \dRp+
                                Publication p1
-  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root
-----------+------------+---------+---------+---------+-----------+----------
- postgres | f          | t       | t       | t       | t         | f
+  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+----------+------------+---------+---------+---------+-----------+------------------------------
+ postgres | f          | t       | t       | t       | t         | f        |
 Tables:
     "public.t1" WHERE ((a > 5) AND (c = 'NSW'::text))
 
                                Publication p2
-  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root
-----------+------------+---------+---------+---------+-----------+----------
- postgres | f          | t       | t       | t       | t         | f
+  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+----------+------------+---------+---------+---------+-----------+------------------------------
+ postgres | f          | t       | t       | t       | t         | f        |
 Tables:
     "public.t1"
     "public.t2" WHERE (e = 99)
 
                                Publication p3
-  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root
-----------+------------+---------+---------+---------+-----------+----------
- postgres | f          | t       | t       | t       | t         | f
+  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+----------+------------+---------+---------+---------+-----------+------------------------------
+ postgres | f          | t       | t       | t       | t         | f        |
 Tables:
     "public.t2" WHERE (d = 10)
     "public.t3" WHERE (g = 10)
@@ -1259,10 +1259,11 @@ test_sub=# SELECT * FROM child ORDER BY a;
 
   <para>
    The choice of columns can be based on behavioral or performance reasons.
-   However, do not rely on this feature for security: a malicious subscriber
-   is able to obtain data from columns that are not specifically
-   published.  If security is a consideration, protections can be applied
-   at the publisher side.
+   However, if you want to use this feature for security, please consider
+   using the privileges on publication, as explained in
+   <xref linkend="logical-replication-security"/>. Otherwise a malicious
+   subscriber may be able to use other publications to obtain data from
+   columns that are not specifically published via your publication.
   </para>
 
   <para>
@@ -1360,9 +1361,9 @@ CREATE PUBLICATION
 <programlisting>
 test_pub=# \dRp+
                                Publication p1
-  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root
-----------+------------+---------+---------+---------+-----------+----------
- postgres | f          | t       | t       | t       | t         | f
+  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+----------+------------+---------+---------+---------+-----------+------------------------------
+ postgres | f          | t       | t       | t       | t         | f        |
 Tables:
     "public.t1" (id, a, b, d)
 </programlisting></para>
@@ -1724,12 +1725,6 @@ CONTEXT:  processing remote data for replication origin "pg_16395" during "INSER
    and it must have the <literal>LOGIN</literal> attribute.
   </para>
 
-  <para>
-   In order to be able to copy the initial table data, the role used for the
-   replication connection must have the <literal>SELECT</literal> privilege on
-   a published table (or be a superuser).
-  </para>
-
   <para>
    To create a publication, the user must have the <literal>CREATE</literal>
    privilege in the database.
@@ -1743,16 +1738,25 @@ CONTEXT:  processing remote data for replication origin "pg_16395" during "INSER
   </para>
 
   <para>
-   There are currently no privileges on publications.  Any subscription (that
-   is able to connect) can access any publication.  Thus, if you intend to
-   hide some information from particular subscribers, such as by using row
-   filters or column lists, or by not adding the whole table to the
-   publication, be aware that other publications in the same database could
-   expose the same information.  Publication privileges might be added to
-   <productname>PostgreSQL</productname> in the future to allow for
-   finer-grained access control.
+   To replicate data, the role used for the replication connection must have
+   the <literal>USAGE</literal> privilege on the publication. In such a case,
+   the subscription role needs neither the <literal>SELECT</literal>
+   privileges on the replicated tables nor the <literal>USAGE</literal>
+   privilege on the containing schemas.
   </para>
 
+  <note>
+   <para>
+    The <literal>USAGE</literal> privilege on publication is only checked if
+    the <link linkend="guc-publication-security"><varname>publication_security</varname></link>
+    configuration parameter is set. The default is <literal>off</literal>. It
+    should only be set to <literal>on</literal> if all the subscribers are
+    on <productname>PostgreSQL</productname> server version 16 or later. The
+    older versions do not send the publication names for the initial table
+    synchronization, so they would fail to receive the data.
+   </para>
+  </note>
+
   <para>
    To create a subscription, the user must be a superuser.
   </para>
@@ -1812,6 +1816,12 @@ CONTEXT:  processing remote data for replication origin "pg_16395" during "INSER
     <link linkend="guc-wal-sender-timeout"><varname>wal_sender_timeout</varname></link>.
    </para>
 
+   <para>
+    <link linkend="guc-publication-security"><varname>publication_security</varname></link>
+    must be set to <literal>on</literal> if the publisher is supposed to check
+    the publication privileges.
+   </para>
+
   </sect2>
 
   <sect2 id="logical-replication-config-subscriber">
diff --git a/doc/src/sgml/ref/copy.sgml b/doc/src/sgml/ref/copy.sgml
index 5e591ed2e6..3bc199e701 100644
--- a/doc/src/sgml/ref/copy.sgml
+++ b/doc/src/sgml/ref/copy.sgml
@@ -44,6 +44,7 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable
     FORCE_NULL ( <replaceable class="parameter">column_name</replaceable> [, ...] )
     ENCODING '<replaceable class="parameter">encoding_name</replaceable>'
     DEFAULT '<replaceable class="parameter">default_string</replaceable>'
+    PUBLICATION_NAMES ( <replaceable class="parameter">publication_name</replaceable> [, ...] )
 </synopsis>
  </refsynopsisdiv>
 
@@ -382,6 +383,41 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term><replaceable class="parameter">publication_name</replaceable></term>
+    <listitem>
+     <para>
+      The name of an
+      existing <link linkend="logical-replication-publication">publication</link>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><literal>PUBLICATION_NAMES</literal></term>
+    <listitem>
+     <para>
+      Specifies a list of publications. Only rows that match the
+      <link linkend="logical-replication-row-filter">row filter</link> of at
+      least one the publications are copied. If at least one publication in
+      the list has no row filter, the whole table contents will be copied.
+     </para>
+     <para>
+      If
+      the <link linkend="guc-publication-security">publication_security</link>
+      configuration parameter is <literal>on</literal>, the list is required.
+      and the user needs to have the <literal>USAGE</literal> privilege on all
+      the publications in the list which are actually used to retrieve the
+      data from given table.
+     </para>
+     <para>
+      This option is allowed only in <command>COPY TO</command>. Currently,
+      only the users with the <literal>REPLICATION</literal> privilege can use
+      this option.
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term><literal>WHERE</literal></term>
     <listitem>
diff --git a/doc/src/sgml/ref/grant.sgml b/doc/src/sgml/ref/grant.sgml
index 35bf0332c8..329a4f9023 100644
--- a/doc/src/sgml/ref/grant.sgml
+++ b/doc/src/sgml/ref/grant.sgml
@@ -82,6 +82,11 @@ GRANT { { SET | ALTER SYSTEM } [, ... ] | ALL [ PRIVILEGES ] }
     TO <replaceable class="parameter">role_specification</replaceable> [, ...] [ WITH GRANT OPTION ]
     [ GRANTED BY <replaceable class="parameter">role_specification</replaceable> ]
 
+GRANT { USAGE [, ... ] | ALL [ PRIVILEGES ] }
+    ON PUBLICATION <replaceable class="parameter">publication_name</replaceable> [, ...]
+    TO <replaceable class="parameter">role_specification</replaceable> [, ...] [ WITH GRANT OPTION ]
+    [ GRANTED BY <replaceable class="parameter">role_specification</replaceable> ]
+
 GRANT { { CREATE | USAGE } [, ...] | ALL [ PRIVILEGES ] }
     ON SCHEMA <replaceable>schema_name</replaceable> [, ...]
     TO <replaceable class="parameter">role_specification</replaceable> [, ...] [ WITH GRANT OPTION ]
@@ -513,8 +518,8 @@ GRANT admins TO joe;
    </para>
 
    <para>
-    Privileges on databases, tablespaces, schemas, languages, and
-    configuration parameters are
+    Privileges on databases, tablespaces, schemas, languages, configuration
+    parameters and publications are
     <productname>PostgreSQL</productname> extensions.
    </para>
  </refsect1>
diff --git a/src/backend/catalog/aclchk.c b/src/backend/catalog/aclchk.c
index c4232344aa..b7dc203859 100644
--- a/src/backend/catalog/aclchk.c
+++ b/src/backend/catalog/aclchk.c
@@ -253,6 +253,9 @@ restrict_and_check_grant(bool is_grant, AclMode avail_goptions, bool all_privs,
         case OBJECT_FUNCTION:
             whole_mask = ACL_ALL_RIGHTS_FUNCTION;
             break;
+        case OBJECT_PUBLICATION:
+            whole_mask = ACL_ALL_RIGHTS_PUBLICATION;
+            break;
         case OBJECT_LANGUAGE:
             whole_mask = ACL_ALL_RIGHTS_LANGUAGE;
             break;
@@ -485,6 +488,10 @@ ExecuteGrantStmt(GrantStmt *stmt)
             all_privileges = ACL_ALL_RIGHTS_FUNCTION;
             errormsg = gettext_noop("invalid privilege type %s for function");
             break;
+        case OBJECT_PUBLICATION:
+            all_privileges = ACL_ALL_RIGHTS_PUBLICATION;
+            errormsg = gettext_noop("invalid privilege type %s for publication");
+            break;
         case OBJECT_LANGUAGE:
             all_privileges = ACL_ALL_RIGHTS_LANGUAGE;
             errormsg = gettext_noop("invalid privilege type %s for language");
@@ -621,6 +628,9 @@ ExecGrantStmt_oids(InternalGrant *istmt)
         case OBJECT_LARGEOBJECT:
             ExecGrant_Largeobject(istmt);
             break;
+        case OBJECT_PUBLICATION:
+            ExecGrant_common(istmt, PublicationRelationId, ACL_ALL_RIGHTS_PUBLICATION, NULL);
+            break;
         case OBJECT_SCHEMA:
             ExecGrant_common(istmt, NamespaceRelationId, ACL_ALL_RIGHTS_SCHEMA, NULL);
             break;
@@ -731,6 +741,16 @@ objectNamesToOids(ObjectType objtype, List *objnames, bool is_grant)
                 objects = lappend_oid(objects, lobjOid);
             }
             break;
+        case OBJECT_PUBLICATION:
+            foreach(cell, objnames)
+            {
+                char       *nspname = strVal(lfirst(cell));
+                Oid            oid;
+
+                oid = get_publication_oid(nspname, false);
+                objects = lappend_oid(objects, oid);
+            }
+            break;
         case OBJECT_SCHEMA:
             foreach(cell, objnames)
             {
@@ -3023,6 +3043,8 @@ pg_aclmask(ObjectType objtype, Oid object_oid, AttrNumber attnum, Oid roleid,
             return object_aclmask(DatabaseRelationId, object_oid, roleid, mask, how);
         case OBJECT_FUNCTION:
             return object_aclmask(ProcedureRelationId, object_oid, roleid, mask, how);
+        case OBJECT_PUBLICATION:
+            return object_aclmask(PublicationRelationId, object_oid, roleid, mask, how);
         case OBJECT_LANGUAGE:
             return object_aclmask(LanguageRelationId, object_oid, roleid, mask, how);
         case OBJECT_LARGEOBJECT:
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index 14e57adee2..d76b052059 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -2936,7 +2936,6 @@ Oid
 LookupExplicitNamespace(const char *nspname, bool missing_ok)
 {
     Oid            namespaceId;
-    AclResult    aclresult;
 
     /* check for pg_temp alias */
     if (strcmp(nspname, "pg_temp") == 0)
@@ -2955,10 +2954,20 @@ LookupExplicitNamespace(const char *nspname, bool missing_ok)
     if (missing_ok && !OidIsValid(namespaceId))
         return InvalidOid;
 
-    aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(), ACL_USAGE);
-    if (aclresult != ACLCHECK_OK)
-        aclcheck_error(aclresult, OBJECT_SCHEMA,
-                       nspname);
+    /*
+     * If the publication security is active, bypass the standard security
+     * checks.
+     */
+    if (!publication_security)
+    {
+        AclResult    aclresult;
+
+        aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(),
+                                    ACL_USAGE);
+        if (aclresult != ACLCHECK_OK)
+            aclcheck_error(aclresult, OBJECT_SCHEMA,
+                           nspname);
+    }
     /* Schema search hook for this lookup */
     InvokeNamespaceSearchHook(namespaceId, true);
 
@@ -3835,10 +3844,16 @@ recomputeNamespacePath(void)
                 rname = NameStr(((Form_pg_authid) GETSTRUCT(tuple))->rolname);
                 namespaceId = get_namespace_oid(rname, true);
                 ReleaseSysCache(tuple);
+
+                /*
+                 * If the publication security is active, bypass the standard
+                 * security checks.
+                 */
                 if (OidIsValid(namespaceId) &&
                     !list_member_oid(oidlist, namespaceId) &&
-                    object_aclcheck(NamespaceRelationId, namespaceId, roleid,
-                                          ACL_USAGE) == ACLCHECK_OK &&
+                    (publication_security ||
+                     object_aclcheck(NamespaceRelationId, namespaceId, roleid,
+                                     ACL_USAGE) == ACLCHECK_OK) &&
                     InvokeNamespaceSearchHook(namespaceId, false))
                     oidlist = lappend_oid(oidlist, namespaceId);
             }
@@ -3865,8 +3880,9 @@ recomputeNamespacePath(void)
             namespaceId = get_namespace_oid(curname, true);
             if (OidIsValid(namespaceId) &&
                 !list_member_oid(oidlist, namespaceId) &&
-                object_aclcheck(NamespaceRelationId, namespaceId, roleid,
-                                      ACL_USAGE) == ACLCHECK_OK &&
+                (publication_security ||
+                 object_aclcheck(NamespaceRelationId, namespaceId, roleid,
+                                 ACL_USAGE) == ACLCHECK_OK) &&
                 InvokeNamespaceSearchHook(namespaceId, false))
                 oidlist = lappend_oid(oidlist, namespaceId);
         }
diff --git a/src/backend/catalog/objectaddress.c b/src/backend/catalog/objectaddress.c
index 2f688166e1..31e7599111 100644
--- a/src/backend/catalog/objectaddress.c
+++ b/src/backend/catalog/objectaddress.c
@@ -587,7 +587,7 @@ static const ObjectPropertyType ObjectProperty[] =
         Anum_pg_publication_pubname,
         InvalidAttrNumber,
         Anum_pg_publication_pubowner,
-        InvalidAttrNumber,
+        Anum_pg_publication_pubacl,
         OBJECT_PUBLICATION,
         true
     },
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7f6024b7a5..93793b1fa4 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -1069,9 +1069,11 @@ GetPublicationRelationMapping(Oid pubid, Oid relid,
         *qual_isnull = true;
     }
 }
+
 /*
  * Pick those publications from a list which should actually be used to
- * publish given relation and return them.
+ * publish given relation, check their USAGE privilege is needed and return
+ * them.
  *
  * If publish_as_relid_p is passed, the relation whose tuple descriptor should
  * be used to publish the data is stored in *publish_as_relid_p.
@@ -1165,6 +1167,22 @@ GetEffectiveRelationPublications(Oid relid, List *publications,
                 publish = true;
         }
 
+        /*
+         * Check privileges before we use any information of the
+         * publication.
+         */
+        if (publication_security && publish)
+        {
+            Oid            roleid = GetUserId();
+            AclResult    aclresult;
+
+            aclresult = object_aclcheck(PublicationRelationId, pub->oid,
+                                        roleid, ACL_USAGE);
+            if (aclresult != ACLCHECK_OK)
+                aclcheck_error(aclresult, OBJECT_PUBLICATION,
+                               get_publication_name(pub->oid, false));
+        }
+
         /*
          * If the relation is to be published, determine actions to publish,
          * and list of columns, if appropriate.
diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c
index 8edc2c19f6..6504a27771 100644
--- a/src/backend/commands/copy.c
+++ b/src/backend/commands/copy.c
@@ -41,6 +41,8 @@
 #include "utils/rel.h"
 #include "utils/rls.h"
 
+static bool isReplicationUser(void);
+
 /*
  *     DoCopy executes the SQL COPY statement
  *
@@ -71,6 +73,7 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
     Oid            relid;
     RawStmt    *query = NULL;
     Node       *whereClause = NULL;
+    List        *publication_names = NIL;
 
     /*
      * Disallow COPY to/from file or program except to users with the
@@ -105,14 +108,23 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
         }
     }
 
+    /*
+     * It seems more useful to tell the user immediately that something is
+     * wrong about the use of the PUBLICATION_NAMES option than to complain
+     * about missing SELECT privilege below: whoever is authorized to use this
+     * option shouldn't need the SELECT privilege at all. Therefore check the
+     * PUBLICATION_NAMES option earlier than the other options.  XXX Shouldn't
+     * we check all the options here anyway?
+     */
+    publication_names = ProcessCopyToPublicationOptions(pstate,
+                                                        stmt->options,
+                                                        stmt->is_from);
+
     if (stmt->relation)
     {
         LOCKMODE    lockmode = is_from ? RowExclusiveLock : AccessShareLock;
         ParseNamespaceItem *nsitem;
         RTEPermissionInfo *perminfo;
-        TupleDesc    tupDesc;
-        List       *attnums;
-        ListCell   *cur;
 
         Assert(!stmt->query);
 
@@ -127,6 +139,14 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
         perminfo = nsitem->p_perminfo;
         perminfo->requiredPerms = (is_from ? ACL_INSERT : ACL_SELECT);
 
+        /*
+         * The access by a replication user is controlled by the publication
+         * privileges, ACL_SELECT is not required. The actual checks of the
+         * publication privileges will take place later.
+         */
+        if (!is_from && publication_security)
+            perminfo->requiredPerms &= ~ACL_SELECT;
+
         if (stmt->whereClause)
         {
             /* add nsitem to query namespace */
@@ -147,19 +167,31 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
             whereClause = (Node *) make_ands_implicit((Expr *) whereClause);
         }
 
-        tupDesc = RelationGetDescr(rel);
-        attnums = CopyGetAttnums(tupDesc, rel, stmt->attlist);
-        foreach(cur, attnums)
+        /*
+         * If publication row filters need to be applied, the query form of
+         * COPY TO is used, so the permissions will be checked by the
+         * executor. Otherwise check the permissions now.
+         */
+        if (publication_names == NIL)
         {
-            int            attno;
-            Bitmapset **bms;
+            TupleDesc    tupDesc;
+            List       *attnums;
+            ListCell   *cur;
 
-            attno = lfirst_int(cur) - FirstLowInvalidHeapAttributeNumber;
-            bms = is_from ? &perminfo->insertedCols : &perminfo->selectedCols;
+            tupDesc = RelationGetDescr(rel);
+            attnums = CopyGetAttnums(tupDesc, rel, stmt->attlist);
+            foreach(cur, attnums)
+            {
+                int            attno;
+                Bitmapset **bms;
 
-            *bms = bms_add_member(*bms, attno);
+                attno = lfirst_int(cur) - FirstLowInvalidHeapAttributeNumber;
+                bms = is_from ? &perminfo->insertedCols : &perminfo->selectedCols;
+
+                *bms = bms_add_member(*bms, attno);
+            }
+            ExecCheckPermissions(pstate->p_rtable, list_make1(perminfo), true);
         }
-        ExecCheckPermissions(pstate->p_rtable, list_make1(perminfo), true);
 
         /*
          * Permission check for row security policies.
@@ -184,6 +216,7 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
                          errhint("Use INSERT statements instead.")));
 
             query = CreateCopyToQuery(stmt, rel, stmt_location, stmt_len);
+
             /*
              * Close the relation for now, but keep the lock on it to prevent
              * changes between now and when we start the query-based COPY.
@@ -232,10 +265,24 @@ DoCopy(ParseState *pstate, const CopyStmt *stmt,
     else
     {
         CopyToState cstate;
+        Relation    rel_loc = rel;
 
-        cstate = BeginCopyTo(pstate, rel, query, relid,
+        /*
+         * If publication row filters need to be applied, use the "COPY query
+         * TO ..."  form of the command.
+         */
+        if (rel && publication_names)
+        {
+            query = CreateCopyToQuery(stmt, rel, stmt_location, stmt_len);
+
+            /* BeginCopyTo() should only receive the query.  */
+            rel_loc = NULL;
+        }
+
+        cstate = BeginCopyTo(pstate, rel_loc, query, relid,
                              stmt->filename, stmt->is_program,
-                             NULL, stmt->attlist, stmt->options);
+                             NULL, stmt->attlist, stmt->options,
+                             publication_names);
         *processed = DoCopyTo(cstate);    /* copy from database to file */
         EndCopyTo(cstate);
     }
@@ -482,6 +529,13 @@ ProcessCopyOptions(ParseState *pstate,
                                 defel->defname),
                          parser_errposition(pstate, defel->location)));
         }
+        else if (strcmp(defel->defname, "publication_names") == 0)
+        {
+            /*
+             * ProcessCopyToPublicationOptions() should have been checked this
+             * already.
+             */
+        }
         else
             ereport(ERROR,
                     (errcode(ERRCODE_SYNTAX_ERROR),
@@ -679,6 +733,78 @@ ProcessCopyOptions(ParseState *pstate,
     }
 }
 
+/*
+ * Check the PUBLICATION_NAMES option of the "COPY TO" command.
+ *
+ * This option is checked separate from others.
+ */
+List *
+ProcessCopyToPublicationOptions(ParseState *pstate, List *options,
+                                bool is_from)
+{
+    ListCell   *option;
+    bool    found = false;
+    List    *result = NIL;
+
+    /* Extract options from the statement node tree */
+    foreach(option, options)
+    {
+        DefElem    *defel = lfirst_node(DefElem, option);
+
+        if (strcmp(defel->defname, "publication_names") == 0)
+        {
+            if (is_from)
+                ereport(ERROR,
+                        errmsg("PUBLICATION_NAMES option only available using COPY TO"));
+
+            if (result)
+                errorConflictingDefElem(defel, pstate);
+            found = true;
+            if (defel->arg == NULL || IsA(defel->arg, List))
+                result = castNode(List, defel->arg);
+            else
+                ereport(ERROR,
+                        (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+                         errmsg("argument to option \"%s\" must be a list of publication names",
+                                defel->defname),
+                         parser_errposition(pstate, defel->location)));
+        }
+    }
+
+    /*
+     * If the publication security is enabled, subscriber must send the list
+     * of publication in order to tell which subset of the data it is
+     * authorized to receive.
+     *
+     * publication_security does not affect sessions of non-replication users.
+     */
+    if (!found && publication_security && isReplicationUser())
+    {
+        /*
+         * This probably means that an old version of subscriber tries to get
+         * data from a secured publisher.
+         */
+        ereport(ERROR,
+                (errmsg("publication security requires the PUBLICATION_NAMES option")));
+    }
+
+    /*
+     * The option does only make sense in the context of (logical)
+     * replication. We could allow it for non-replication users too, but then
+     * we'd have to require it publication_security is on like above and thus
+     * break existing client code.
+     */
+    if (found && !isReplicationUser())
+        ereport(ERROR,
+                (errmsg("PUBLICATION_NAMES may only be used by roles with the REPLICATION privilege")));
+
+    if (found && result == NIL)
+        ereport(ERROR,
+                (errmsg("the value of the PUBLICATION_NAMES option must not be empty")));
+
+    return result;
+}
+
 /*
  * CopyGetAttnums - build an integer list of attnums to be copied
  *
@@ -769,3 +895,17 @@ CopyGetAttnums(TupleDesc tupDesc, Relation rel, List *attnamelist)
 
     return attnums;
 }
+
+/*
+ * Check whether the current session can use the USAGE privilege on
+ * publications instead of the SELECT privileges on tables.
+ *
+ * Superuser makes the test pass too so that subscriptions which connect to
+ * the publisher as superuser work fine.
+ */
+static bool
+isReplicationUser(void)
+{
+    return has_rolreplication(GetUserId()) || superuser();
+
+}
diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c
index af0cdef158..fd508b592f 100644
--- a/src/backend/commands/copyto.c
+++ b/src/backend/commands/copyto.c
@@ -34,13 +34,18 @@
 #include "miscadmin.h"
 #include "nodes/makefuncs.h"
 #include "optimizer/optimizer.h"
+#include "parser/parsetree.h"
+#include "parser/parse_relation.h"
 #include "pgstat.h"
 #include "rewrite/rewriteHandler.h"
+#include "rewrite/rewriteManip.h"
 #include "storage/fd.h"
 #include "tcop/tcopprot.h"
+#include "utils/builtins.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/partcache.h"
+#include "utils/acl.h"
 #include "utils/rel.h"
 #include "utils/snapmgr.h"
 
@@ -132,6 +137,10 @@ static void CopySendEndOfRow(CopyToState cstate);
 static void CopySendInt32(CopyToState cstate, int32 val);
 static void CopySendInt16(CopyToState cstate, int16 val);
 
+static void AddPublicationFiltersToQuery(CopyToState cstate, Query *query,
+                                         List *publication_names);
+static Node *GetPublicationFilters(Relation rel, List *publications,
+                                   int varno);
 
 /*
  * Send copy start/stop messages for frontend copies.  These have changed
@@ -439,6 +448,7 @@ CreateCopyToQuery(const CopyStmt *stmt, Relation rel, int stmt_location,
  * 'data_dest_cb': Callback that processes the output data
  * 'attnamelist': List of char *, columns to include. NIL selects all cols.
  * 'options': List of DefElem. See copy_opt_item in gram.y for selections.
+ * 'publication_names': PUBLICATION_NAMES option (also contained in 'options')
  *
  * Returns a CopyToState, to be passed to DoCopyTo() and related functions.
  */
@@ -451,7 +461,8 @@ BeginCopyTo(ParseState *pstate,
             bool is_program,
             copy_data_dest_cb data_dest_cb,
             List *attnamelist,
-            List *options)
+            List *options,
+            List *publication_names)
 {
     CopyToState cstate;
     bool        pipe = (filename == NULL && data_dest_cb == NULL);
@@ -606,6 +617,12 @@ BeginCopyTo(ParseState *pstate,
                      errmsg("COPY query must have a RETURNING clause")));
         }
 
+        /*
+         * If the subscriber passed the publication names, use them.
+         */
+        if (publication_names)
+            AddPublicationFiltersToQuery(cstate, query, publication_names);
+
         /* plan the query */
         plan = pg_plan_query(query, pstate->p_sourcetext,
                              CURSOR_OPT_PARALLEL_OK, NULL);
@@ -1376,3 +1393,202 @@ CreateCopyDestReceiver(void)
 
     return (DestReceiver *) self;
 }
+
+/*
+ * For each table in the query add the row filters of the related publication
+ * to the WHERE clause. While doing so, check if the current user has the
+ * USAGE privilege on the publications.
+ */
+static void
+AddPublicationFiltersToQuery(CopyToState cstate, Query *query,
+                             List *publication_names)
+{
+    List    *publications = NIL;
+    Index rtindex;
+    FromExpr   *from_expr;
+    ListCell    *lc;
+
+    Assert(publication_names);
+
+    /* Convert the list of names to a list of OIDs. */
+    foreach(lc, publication_names)
+    {
+        char    *pubname = strVal(lfirst(lc));
+        Oid        pubid;
+        Publication    *pub;
+
+        pubid = get_publication_oid(pubname, true);
+        if (pubid == InvalidOid)
+        {
+            ereport(WARNING,
+                (errcode(ERRCODE_UNDEFINED_OBJECT),
+                 errmsg("publication \"%s\" does not exist", pubname)));
+            continue;
+        }
+
+        pub = GetPublication(pubid);
+
+        publications = lappend(publications, pub);
+    }
+
+    if (publications == NIL)
+        ereport(ERROR, errmsg("no valid publication received"));
+
+    /*
+     * If the query references at least one table, construct or adjust the
+     * WHERE clause according to the publications.
+     */
+    from_expr = query->jointree;
+
+    rtindex = 1;
+    foreach(lc, query->rtable)
+    {
+        RangeTblEntry *rte;
+        Relation    qrel;
+        List    *pubs_matched;
+        Node    *quals;
+
+        rte = lfirst_node(RangeTblEntry, lc);
+
+        /*
+         * NoLock because the relation should already be locked due to the
+         * prior rewriting.
+         */
+        qrel = relation_open(rte->relid, NoLock);
+
+        /*
+         * Clear ACL_SELECT on each RTE entry if the ACL_USAGE permission on
+         * publications should control the access, see below.
+         */
+        if (publication_security)
+        {
+            RTEPermissionInfo *perminfo;
+
+            perminfo = getRTEPermissionInfo(query->rteperminfos, rte);
+            perminfo->requiredPerms &= ~ACL_SELECT;
+        }
+
+        /*
+         * Retrieve the publications relevant to this relation, and if needed,
+         * check if the current user has the USAGE privilege on them.
+         */
+        pubs_matched = GetEffectiveRelationPublications(RelationGetRelid(qrel),
+                                                        publications, NULL, NULL);
+        if (pubs_matched == NIL)
+            ereport(ERROR,
+                    (errmsg("no publication for relation \"%s\"",
+                            get_rel_name(RelationGetRelid(qrel)))));
+
+        /* Range table implies there should be a FROM list. */
+        Assert(from_expr && from_expr->fromlist);
+
+        /*
+         * Use the publication filters to construct the (additional) filter
+         * expression for this relation.
+         */
+        quals = GetPublicationFilters(qrel, pubs_matched, rtindex);
+        if (quals)
+        {
+            if (from_expr->quals == NULL)
+            {
+                /* Assign a new WHERE clause to the query. */
+                from_expr->quals = quals;
+            }
+            else
+            {
+                List    *new_quals;
+
+                /*
+                 * AND the filter for this relation to the existing WHERE
+                 * clause.
+                 */
+                new_quals = list_make2(quals, from_expr->quals);
+                from_expr->quals = (Node *) make_andclause(new_quals);
+            }
+        }
+
+        list_free(pubs_matched);
+        relation_close(qrel, NoLock);
+        rtindex++;
+    }
+}
+
+/*
+ * Construct WHERE clause for a relation according to the given list of
+ * publications.
+ *
+ * Return NULL if at least one of the publications has no filter.
+ */
+static Node *
+GetPublicationFilters(Relation rel, List *publications, int varno)
+{
+    Oid        relid = RelationGetRelid(rel);
+    List       *filters = NIL;
+    Node       *result = NULL;
+    ListCell   *lc;
+    bool        isvarlena;
+    FmgrInfo    fmgrinfo;
+    Oid            outfunc;
+
+    Assert(publications);
+
+    /* Make sure we're ready call the output function for the node values. */
+    getTypeOutputInfo(PG_NODE_TREEOID, &outfunc, &isvarlena);
+    Assert(isvarlena);
+    fmgr_info(outfunc, &fmgrinfo);
+
+    /* Retrieve the publication filters. */
+    foreach(lc, publications)
+    {
+        Publication        *pub = (Publication *) lfirst(lc);
+        Datum    attrs, qual;
+        bool    attrs_isnull, qual_isnull;
+        char       *nodeStr;
+        Node       *node;
+
+        /* Get the filter expression. */
+        GetPublicationRelationMapping(pub->oid, relid, &attrs, &attrs_isnull,
+                                      &qual, &qual_isnull);
+
+        /*
+         * A single publication w/o expression means that the whole table
+         * should be published.
+         */
+        if (qual_isnull)
+        {
+            if (filters)
+            {
+                list_free_deep(filters);
+                filters = NIL;
+            }
+
+            break;
+        }
+
+        /* Get the filter expression and add it to the list. */
+        nodeStr = OutputFunctionCall(&fmgrinfo, qual);
+        node = stringToNode(nodeStr);
+        pfree(nodeStr);
+
+        /*
+         * Adjust varno so that the expression references the correct
+         * range table entry.
+         */
+        ChangeVarNodes(node, 1, varno, 0);
+
+        /*
+         * XXX Is it worth checking for duplicate expressions in the list?
+         */
+        filters = lappend(filters, node);
+    }
+
+    if (filters)
+    {
+        if (list_length(filters) > 1)
+            result = (Node *) make_orclause(filters);
+        else
+            result = (Node *) linitial(filters);
+    }
+
+    return result;
+}
diff --git a/src/backend/commands/publicationcmds.c b/src/backend/commands/publicationcmds.c
index f4ba572697..d9652604c7 100644
--- a/src/backend/commands/publicationcmds.c
+++ b/src/backend/commands/publicationcmds.c
@@ -800,6 +800,8 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
         BoolGetDatum(pubactions.pubtruncate);
     values[Anum_pg_publication_pubviaroot - 1] =
         BoolGetDatum(publish_via_partition_root);
+    values[Anum_pg_publication_pubowner - 1] = ObjectIdGetDatum(GetUserId());
+    nulls[Anum_pg_publication_pubacl - 1] = true;
 
     tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
 
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index b32f419176..663bfc034c 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -613,7 +613,9 @@ ExecCheckOneRelPerms(RTEPermissionInfo *perminfo)
     Oid            relOid = perminfo->relid;
 
     requiredPerms = perminfo->requiredPerms;
-    Assert(requiredPerms != 0);
+
+    if (requiredPerms == 0)
+        return true;
 
     /*
      * userid to check as: current user unless we have a setuid indication.
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index a0138382a1..fc70aa2057 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -7655,6 +7655,14 @@ privilege_target:
                     n->objs = $2;
                     $$ = n;
                 }
+            | PUBLICATION name_list
+                {
+                    PrivTarget *n = (PrivTarget *) palloc(sizeof(PrivTarget));
+                    n->targtype = ACL_TARGET_OBJECT;
+                    n->objtype = OBJECT_PUBLICATION;
+                    n->objs = $2;
+                    $$ = n;
+                }
             | SCHEMA name_list
                 {
                     PrivTarget *n = (PrivTarget *) palloc(sizeof(PrivTarget));
diff --git a/src/backend/replication/logical/tablesync.c b/src/backend/replication/logical/tablesync.c
index 07eea504ba..2a8cfc5be0 100644
--- a/src/backend/replication/logical/tablesync.c
+++ b/src/backend/replication/logical/tablesync.c
@@ -753,6 +753,27 @@ copy_read_data(void *outbuf, int minread, int maxread)
 }
 
 
+/*
+ * Return a comma-separated list of publications associated with the current
+ * subscriptions.
+ */
+static char *
+get_publication_names(void)
+{
+    StringInfoData buf;
+    ListCell   *lc;
+
+    initStringInfo(&buf);
+    foreach(lc, MySubscription->publications)
+    {
+        if (foreach_current_index(lc) > 0)
+            appendStringInfoString(&buf, ", ");
+        appendStringInfoString(&buf, quote_literal_cstr(strVal(lfirst(lc))));
+    }
+
+    return buf.data;
+}
+
 /*
  * Get information about remote relation in similar fashion the RELATION
  * message provides during replication. This function also returns the relation
@@ -770,7 +791,6 @@ fetch_remote_table_info(char *nspname, char *relname,
     Oid            qualRow[] = {TEXTOID};
     bool        isnull;
     int            natt;
-    ListCell   *lc;
     Bitmapset  *included_cols = NULL;
 
     lrel->nspname = nspname;
@@ -812,7 +832,6 @@ fetch_remote_table_info(char *nspname, char *relname,
     ExecDropSingleTupleTableSlot(slot);
     walrcv_clear_result(res);
 
-
     /*
      * Get column lists for each relation.
      *
@@ -824,15 +843,7 @@ fetch_remote_table_info(char *nspname, char *relname,
         WalRcvExecResult *pubres;
         TupleTableSlot *tslot;
         Oid            attrsRow[] = {INT2VECTOROID};
-        StringInfoData pub_names;
-
-        initStringInfo(&pub_names);
-        foreach(lc, MySubscription->publications)
-        {
-            if (foreach_current_index(lc) > 0)
-                appendStringInfoString(&pub_names, ", ");
-            appendStringInfoString(&pub_names, quote_literal_cstr(strVal(lfirst(lc))));
-        }
+        char       *pub_names = get_publication_names();
 
         /*
          * Fetch info about column lists for the relation (from all the
@@ -849,7 +860,7 @@ fetch_remote_table_info(char *nspname, char *relname,
                          " WHERE gpt.relid = %u AND c.oid = gpt.relid"
                          "   AND p.pubname IN ( %s )",
                          lrel->remoteid,
-                         pub_names.data);
+                         pub_names);
 
         pubres = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data,
                              lengthof(attrsRow), attrsRow);
@@ -904,8 +915,7 @@ fetch_remote_table_info(char *nspname, char *relname,
         ExecDropSingleTupleTableSlot(tslot);
 
         walrcv_clear_result(pubres);
-
-        pfree(pub_names.data);
+        pfree(pub_names);
     }
 
     /*
@@ -986,6 +996,18 @@ fetch_remote_table_info(char *nspname, char *relname,
 
     walrcv_clear_result(res);
 
+    lrel->pubnames = NULL;
+    if (walrcv_server_version(LogRepWorkerWalRcvConn) >= 160000)
+    {
+        /*
+         * If the publication ACL is implemented, the publisher is responsible
+         * for checking. All we need to do is to pass the publication names.
+         * The publisher should only return the data matching these
+         * publications and only check the ACLs of these.
+         */
+        lrel->pubnames = get_publication_names();
+    }
+
     /*
      * Get relation's row filter expressions. DISTINCT avoids the same
      * expression of a table in multiple publications from being included
@@ -1005,21 +1027,9 @@ fetch_remote_table_info(char *nspname, char *relname,
      * 3) one of the subscribed publications is declared as TABLES IN SCHEMA
      * that includes this relation
      */
-    if (walrcv_server_version(LogRepWorkerWalRcvConn) >= 150000)
+    else if (walrcv_server_version(LogRepWorkerWalRcvConn) >= 150000)
     {
-        StringInfoData pub_names;
-
-        /* Build the pubname list. */
-        initStringInfo(&pub_names);
-        foreach(lc, MySubscription->publications)
-        {
-            char       *pubname = strVal(lfirst(lc));
-
-            if (foreach_current_index(lc) > 0)
-                appendStringInfoString(&pub_names, ", ");
-
-            appendStringInfoString(&pub_names, quote_literal_cstr(pubname));
-        }
+        char       *pub_names = get_publication_names();
 
         /* Check for row filters. */
         resetStringInfo(&cmd);
@@ -1030,7 +1040,7 @@ fetch_remote_table_info(char *nspname, char *relname,
                          " WHERE gpt.relid = %u"
                          "   AND p.pubname IN ( %s )",
                          lrel->remoteid,
-                         pub_names.data);
+                         pub_names);
 
         res = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data, 1, qualRow);
 
@@ -1069,6 +1079,7 @@ fetch_remote_table_info(char *nspname, char *relname,
         ExecDropSingleTupleTableSlot(slot);
 
         walrcv_clear_result(res);
+        pfree(pub_names);
     }
 
     pfree(cmd.data);
@@ -1105,7 +1116,12 @@ copy_table(Relation rel)
     /* Start copy on the publisher. */
     initStringInfo(&cmd);
 
-    /* Regular table with no row filter */
+    /*
+     * Regular table with no row filter.
+     *
+     * Note that "qual" can also be NIL due to the fact the publisher is
+     * supposed to handle the row filters, so that we didn't check them here.
+     */
     if (lrel.relkind == RELKIND_RELATION && qual == NIL)
     {
         appendStringInfo(&cmd, "COPY %s (",
@@ -1122,8 +1138,6 @@ copy_table(Relation rel)
 
             appendStringInfoString(&cmd, quote_identifier(lrel.attnames[i]));
         }
-
-        appendStringInfoString(&cmd, ") TO STDOUT");
     }
     else
     {
@@ -1165,9 +1179,20 @@ copy_table(Relation rel)
             }
             list_free_deep(qual);
         }
+    }
+
+    appendStringInfoString(&cmd, ") TO STDOUT");
 
-        appendStringInfoString(&cmd, ") TO STDOUT");
+    if (lrel.pubnames)
+    {
+        /*
+         * Tell the publisher which publications we are interested in.
+         * Publishers of recent versions do need this information to construct
+         * the query filter and to check publication privileges.
+         */
+        appendStringInfo(&cmd, " (PUBLICATION_NAMES (%s)) ", lrel.pubnames);
     }
+
     res = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data, 0, NULL);
     pfree(cmd.data);
     if (res->status != WALRCV_OK_COPY_OUT)
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 21b8b2944e..6c2c5add1c 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -14,6 +14,7 @@
 
 #include "access/tupconvert.h"
 #include "catalog/partition.h"
+#include "catalog/pg_authid.h"
 #include "catalog/pg_publication.h"
 #include "catalog/pg_publication_rel.h"
 #include "catalog/pg_subscription.h"
@@ -21,6 +22,7 @@
 #include "commands/subscriptioncmds.h"
 #include "executor/executor.h"
 #include "fmgr.h"
+#include "miscadmin.h"
 #include "nodes/makefuncs.h"
 #include "optimizer/optimizer.h"
 #include "parser/parse_relation.h"
@@ -28,6 +30,7 @@
 #include "replication/logicalproto.h"
 #include "replication/origin.h"
 #include "replication/pgoutput.h"
+#include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
diff --git a/src/backend/replication/walsender.c b/src/backend/replication/walsender.c
index 75e8363e24..694a9828bb 100644
--- a/src/backend/replication/walsender.c
+++ b/src/backend/replication/walsender.c
@@ -125,6 +125,12 @@ int            wal_sender_timeout = 60 * 1000; /* maximum time to send one WAL
                                              * data message */
 bool        log_replication_commands = false;
 
+/*
+ * Should USAGE privilege on publications be checked? Defaults to false so
+ * that server upgrade does not break existing logical replication.
+ */
+bool        publication_security = false;
+
 /*
  * State for WalSndWakeupRequest
  */
diff --git a/src/backend/utils/adt/acl.c b/src/backend/utils/adt/acl.c
index 8f7522d103..8c318676e1 100644
--- a/src/backend/utils/adt/acl.c
+++ b/src/backend/utils/adt/acl.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_namespace.h"
 #include "catalog/pg_parameter_acl.h"
 #include "catalog/pg_proc.h"
+#include "catalog/pg_publication.h"
 #include "catalog/pg_tablespace.h"
 #include "catalog/pg_type.h"
 #include "commands/dbcommands.h"
@@ -118,6 +119,7 @@ static AclMode convert_tablespace_priv_string(text *priv_type_text);
 static Oid    convert_type_name(text *typename);
 static AclMode convert_type_priv_string(text *priv_type_text);
 static AclMode convert_parameter_priv_string(text *priv_text);
+static AclMode convert_publication_priv_string(text *priv_type_text);
 static AclMode convert_role_priv_string(text *priv_type_text);
 static AclResult pg_role_aclcheck(Oid role_oid, Oid roleid, AclMode mode);
 
@@ -844,6 +846,10 @@ acldefault(ObjectType objtype, Oid ownerId)
             world_default = ACL_NO_RIGHTS;
             owner_default = ACL_ALL_RIGHTS_PARAMETER_ACL;
             break;
+        case OBJECT_PUBLICATION:
+            world_default = ACL_USAGE;
+            owner_default = ACL_ALL_RIGHTS_PUBLICATION;
+            break;
         default:
             elog(ERROR, "unrecognized object type: %d", (int) objtype);
             world_default = ACL_NO_RIGHTS;    /* keep compiler quiet */
@@ -929,6 +935,9 @@ acldefault_sql(PG_FUNCTION_ARGS)
         case 'p':
             objtype = OBJECT_PARAMETER_ACL;
             break;
+        case 'P':
+            objtype = OBJECT_PUBLICATION;
+            break;
         case 't':
             objtype = OBJECT_TABLESPACE;
             break;
@@ -4558,6 +4567,48 @@ convert_parameter_priv_string(text *priv_text)
     return convert_any_priv_string(priv_text, parameter_priv_map);
 }
 
+/*
+ * has_publication_privilege_id
+ *        Check user privileges on a publication given
+ *        publication oid and text priv name.
+ *        current_user is assumed
+ */
+Datum
+has_publication_privilege_id(PG_FUNCTION_ARGS)
+{
+    Oid            puboid = PG_GETARG_OID(0);
+    text       *priv_type_text = PG_GETARG_TEXT_PP(1);
+    Oid            roleid;
+    AclMode        mode;
+    AclResult    aclresult;
+
+    roleid = GetUserId();
+    mode = convert_publication_priv_string(priv_type_text);
+
+    if (!SearchSysCacheExists1(PUBLICATIONOID, ObjectIdGetDatum(puboid)))
+        PG_RETURN_NULL();
+
+    aclresult = object_aclcheck(PublicationRelationId, puboid, roleid, mode);
+
+    PG_RETURN_BOOL(aclresult == ACLCHECK_OK);
+}
+
+/*
+ * convert_publication_priv_string
+ *        Convert text string to AclMode value.
+ */
+static AclMode
+convert_publication_priv_string(text *priv_type_text)
+{
+    static const priv_map type_priv_map[] = {
+        {"USAGE", ACL_USAGE},
+        {"USAGE WITH GRANT OPTION", ACL_GRANT_OPTION_FOR(ACL_USAGE)},
+        {NULL, 0}
+    };
+
+    return convert_any_priv_string(priv_type_text, type_priv_map);
+}
+
 /*
  * pg_has_role variants
  *        These are all named "pg_has_role" at the SQL level.
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 1c0583fe26..90ef11eef9 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -686,6 +686,8 @@ const char *const config_group_names[] =
     gettext_noop("Replication / Primary Server"),
     /* REPLICATION_STANDBY */
     gettext_noop("Replication / Standby Servers"),
+    /* REPLICATION_PUBLISHERS */
+    gettext_noop("Replication / Publishers"),
     /* REPLICATION_SUBSCRIBERS */
     gettext_noop("Replication / Subscribers"),
     /* QUERY_TUNING_METHOD */
@@ -1973,6 +1975,16 @@ struct config_bool ConfigureNamesBool[] =
         NULL, NULL, NULL
     },
 
+    {
+        {"publication_security", PGC_SUSET, REPLICATION_PUBLISHERS,
+            gettext_noop("Enable publication security."),
+            gettext_noop("When enabled, the USAGE privilege is needed to access publications.")
+        },
+        &publication_security,
+        false,
+        NULL, NULL, NULL
+    },
+
     /* End-of-list marker */
     {
         {NULL, 0, 0, NULL, NULL}, NULL, false, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index d06074b86f..720b7157c2 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -353,6 +353,12 @@
                     # retrieve WAL after a failed attempt
 #recovery_min_apply_delay = 0        # minimum delay for applying changes during recovery
 
+# - Publishers -
+
+# These settings are ignored on a subscriber.
+
+#publication_security = off        # should publication privileges be checked?
+
 # - Subscribers -
 
 # These settings are ignored on a publisher.
diff --git a/src/bin/pg_dump/dumputils.c b/src/bin/pg_dump/dumputils.c
index 079693585c..4b0d1b5d27 100644
--- a/src/bin/pg_dump/dumputils.c
+++ b/src/bin/pg_dump/dumputils.c
@@ -511,6 +511,8 @@ do { \
         CONVERT_PRIV('r', "SELECT");
         CONVERT_PRIV('w', "UPDATE");
     }
+    else if (strcmp(type, "PUBLICATION") == 0)
+        CONVERT_PRIV('U', "USAGE");
     else
         abort();
 
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 2e068c6620..a14c8738ef 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -4025,6 +4025,8 @@ getPublications(Archive *fout, int *numPublications)
     int            i_pubdelete;
     int            i_pubtruncate;
     int            i_pubviaroot;
+    int            i_pubacl;
+    int            i_acldefault;
     int            i,
                 ntups;
 
@@ -4039,27 +4041,32 @@ getPublications(Archive *fout, int *numPublications)
     resetPQExpBuffer(query);
 
     /* Get the publications. */
-    if (fout->remoteVersion >= 130000)
+    if (fout->remoteVersion >= 150000)
         appendPQExpBufferStr(query,
-                             "SELECT p.tableoid, p.oid, p.pubname, "
-                             "p.pubowner, "
-                             "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, p.pubviaroot "
-                             "FROM pg_publication p");
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, p.pubviaroot,
p.pubacl,acldefault('P', p.pubowner) AS acldefault "
 
+                          "FROM pg_publication p");
+    else if (fout->remoteVersion >= 130000)
+        appendPQExpBuffer(query,
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, p.pubviaroot, '{}' AS
pubacl,'{}' AS acldefault "
 
+                          "FROM pg_publication p");
     else if (fout->remoteVersion >= 110000)
         appendPQExpBufferStr(query,
-                             "SELECT p.tableoid, p.oid, p.pubname, "
-                             "p.pubowner, "
-                             "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, false AS
pubviaroot" 
-                             "FROM pg_publication p");
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, p.pubtruncate, false AS pubviaroot,
'{}'AS pubacl, '{}' AS acldefault "
 
+                          "FROM pg_publication p");
     else
         appendPQExpBufferStr(query,
-                             "SELECT p.tableoid, p.oid, p.pubname, "
-                             "p.pubowner, "
-                             "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, false AS pubtruncate, false AS
pubviaroot"
 
-                             "FROM pg_publication p");
+                          "SELECT p.tableoid, p.oid, p.pubname, "
+                          "p.pubowner, "
+                          "p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, false AS pubtruncate, false AS
pubviaroot,'{}' AS pubacl, '{}' AS acldefault "
 
+                          "FROM pg_publication p");
 
     res = ExecuteSqlQuery(fout, query->data, PGRES_TUPLES_OK);
-
     ntups = PQntuples(res);
 
     i_tableoid = PQfnumber(res, "tableoid");
@@ -4072,6 +4079,8 @@ getPublications(Archive *fout, int *numPublications)
     i_pubdelete = PQfnumber(res, "pubdelete");
     i_pubtruncate = PQfnumber(res, "pubtruncate");
     i_pubviaroot = PQfnumber(res, "pubviaroot");
+    i_pubacl = PQfnumber(res, "pubacl");
+    i_acldefault = PQfnumber(res, "acldefault");
 
     pubinfo = pg_malloc(ntups * sizeof(PublicationInfo));
 
@@ -4096,6 +4105,11 @@ getPublications(Archive *fout, int *numPublications)
             (strcmp(PQgetvalue(res, i, i_pubtruncate), "t") == 0);
         pubinfo[i].pubviaroot =
             (strcmp(PQgetvalue(res, i, i_pubviaroot), "t") == 0);
+        pubinfo[i].dacl.acl = pg_strdup(PQgetvalue(res, i, i_pubacl));
+        pubinfo[i].dacl.acldefault = pg_strdup(PQgetvalue(res, i, i_acldefault));
+        pubinfo[i].dacl.privtype = 0;
+        pubinfo[i].dacl.initprivs = NULL;
+        pubinfo[i].dobj.components |= DUMP_COMPONENT_ACL;
 
         /* Decide whether we want to dump it */
         selectDumpableObject(&(pubinfo[i].dobj), fout);
@@ -4199,6 +4213,11 @@ dumpPublication(Archive *fout, const PublicationInfo *pubinfo)
                      NULL, pubinfo->rolname,
                      pubinfo->dobj.catId, 0, pubinfo->dobj.dumpId);
 
+    if (pubinfo->dobj.dump & DUMP_COMPONENT_ACL)
+        dumpACL(fout, pubinfo->dobj.dumpId, InvalidDumpId, "PUBLICATION",
+                pg_strdup(fmtId(pubinfo->dobj.name)), NULL, NULL,
+                pubinfo->rolname, &pubinfo->dacl);
+
     destroyPQExpBuffer(delq);
     destroyPQExpBuffer(query);
     free(qpubname);
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index cdca0b993d..36e9a00cbf 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -612,6 +612,7 @@ typedef struct _policyInfo
 typedef struct _PublicationInfo
 {
     DumpableObject dobj;
+    DumpableAcl dacl;
     const char *rolname;
     bool        puballtables;
     bool        pubinsert;
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 99e28f607e..8746a6ed73 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6308,6 +6308,7 @@ describePublications(const char *pattern)
     PGresult   *res;
     bool        has_pubtruncate;
     bool        has_pubviaroot;
+    bool        has_pubacl;
 
     PQExpBufferData title;
     printTableContent cont;
@@ -6324,6 +6325,7 @@ describePublications(const char *pattern)
 
     has_pubtruncate = (pset.sversion >= 110000);
     has_pubviaroot = (pset.sversion >= 130000);
+    has_pubacl = (pset.sversion >= 160000);
 
     initPQExpBuffer(&buf);
 
@@ -6337,6 +6339,9 @@ describePublications(const char *pattern)
     if (has_pubviaroot)
         appendPQExpBufferStr(&buf,
                              ", pubviaroot");
+    if (has_pubacl)
+        appendPQExpBufferStr(&buf,
+                            ", pubacl");
     appendPQExpBufferStr(&buf,
                          "\nFROM pg_catalog.pg_publication\n");
 
@@ -6388,6 +6393,8 @@ describePublications(const char *pattern)
             ncols++;
         if (has_pubviaroot)
             ncols++;
+        if (has_pubacl)
+            ncols++;
 
         initPQExpBuffer(&title);
         printfPQExpBuffer(&title, _("Publication %s"), pubname);
@@ -6402,6 +6409,8 @@ describePublications(const char *pattern)
             printTableAddHeader(&cont, gettext_noop("Truncates"), true, align);
         if (has_pubviaroot)
             printTableAddHeader(&cont, gettext_noop("Via root"), true, align);
+        if (has_pubacl)
+            printTableAddHeader(&cont, gettext_noop("Access privileges"), true, align);
 
         printTableAddCell(&cont, PQgetvalue(res, i, 2), false, false);
         printTableAddCell(&cont, PQgetvalue(res, i, 3), false, false);
@@ -6412,6 +6421,8 @@ describePublications(const char *pattern)
             printTableAddCell(&cont, PQgetvalue(res, i, 7), false, false);
         if (has_pubviaroot)
             printTableAddCell(&cont, PQgetvalue(res, i, 8), false, false);
+        if (has_pubacl)
+            printTableAddCell(&cont, PQgetvalue(res, i, 9), false, false);
 
         if (!puballtables)
         {
diff --git a/src/bin/psql/tab-complete.c b/src/bin/psql/tab-complete.c
index 42e87b9e49..768db694d8 100644
--- a/src/bin/psql/tab-complete.c
+++ b/src/bin/psql/tab-complete.c
@@ -3940,6 +3940,7 @@ psql_completion(const char *text, int start, int end)
                                             "LARGE OBJECT",
                                             "PARAMETER",
                                             "PROCEDURE",
+                                            "PUBLICATION",
                                             "ROUTINE",
                                             "SCHEMA",
                                             "SEQUENCE",
@@ -3977,6 +3978,8 @@ psql_completion(const char *text, int start, int end)
             COMPLETE_WITH_QUERY(Query_for_list_of_languages);
         else if (TailMatches("PROCEDURE"))
             COMPLETE_WITH_VERSIONED_SCHEMA_QUERY(Query_for_list_of_procedures);
+        else if (TailMatches("PUBLICATION"))
+            COMPLETE_WITH_VERSIONED_QUERY(Query_for_list_of_publications);
         else if (TailMatches("ROUTINE"))
             COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_routines);
         else if (TailMatches("SCHEMA"))
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index fbc4aade49..17f358d419 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -7245,6 +7245,9 @@
 { oid => '2273', descr => 'current user privilege on schema by schema oid',
   proname => 'has_schema_privilege', provolatile => 's', prorettype => 'bool',
   proargtypes => 'oid text', prosrc => 'has_schema_privilege_id' },
+{ oid => '9800', descr => 'current user privilege on publication by publication oid',
+  proname => 'has_publication_privilege', provolatile => 's', prorettype => 'bool',
+  proargtypes => 'oid text', prosrc => 'has_publication_privilege_id' },
 
 { oid => '2390',
   descr => 'user privilege on tablespace by username, tablespace name',
diff --git a/src/include/catalog/pg_publication.h b/src/include/catalog/pg_publication.h
index dab5bc8444..87da458bdb 100644
--- a/src/include/catalog/pg_publication.h
+++ b/src/include/catalog/pg_publication.h
@@ -54,6 +54,12 @@ CATALOG(pg_publication,6104,PublicationRelationId)
 
     /* true if partition changes are published using root schema */
     bool        pubviaroot;
+
+#ifdef CATALOG_VARLEN            /* variable-length fields start here */
+    /* NOTE: These fields are not present in a relcache entry's rd_rel field. */
+    /* access permissions */
+    aclitem        pubacl[1] BKI_DEFAULT(_null_);
+#endif
 } FormData_pg_publication;
 
 /* ----------------
@@ -63,6 +69,8 @@ CATALOG(pg_publication,6104,PublicationRelationId)
  */
 typedef FormData_pg_publication *Form_pg_publication;
 
+DECLARE_TOAST(pg_publication, 9801, 9802);
+
 DECLARE_UNIQUE_INDEX_PKEY(pg_publication_oid_index, 6110, PublicationObjectIndexId, on pg_publication using btree(oid
oid_ops));
 DECLARE_UNIQUE_INDEX(pg_publication_pubname_index, 6111, PublicationNameIndexId, on pg_publication using btree(pubname
name_ops));
 
@@ -136,6 +144,8 @@ typedef enum PublicationPartOpt
     PUBLICATION_PART_ALL,
 } PublicationPartOpt;
 
+extern PGDLLIMPORT bool publication_security;
+
 extern List *GetPublicationRelations(Oid pubid, PublicationPartOpt pub_partopt);
 extern List *GetAllTablesPublications(void);
 extern List *GetAllTablesPublicationRelations(bool pubviaroot);
diff --git a/src/include/commands/copy.h b/src/include/commands/copy.h
index 774b835251..9953091370 100644
--- a/src/include/commands/copy.h
+++ b/src/include/commands/copy.h
@@ -75,6 +75,8 @@ extern void DoCopy(ParseState *pstate, const CopyStmt *stmt,
                    uint64 *processed);
 
 extern void ProcessCopyOptions(ParseState *pstate, CopyFormatOptions *opts_out, bool is_from, List *options);
+extern List *ProcessCopyToPublicationOptions(ParseState *pstate,
+                                             List *options, bool is_from);
 extern CopyFromState BeginCopyFrom(ParseState *pstate, Relation rel, Node *whereClause,
                                    const char *filename,
                                    bool is_program, copy_data_source_cb data_source_cb, List *attnamelist, List
*options);
@@ -96,7 +98,8 @@ extern RawStmt *CreateCopyToQuery(const CopyStmt *stmt, Relation rel,
                                   int stmt_location, int stmt_len);
 extern CopyToState BeginCopyTo(ParseState *pstate, Relation rel, RawStmt *raw_query,
                                Oid queryRelId, const char *filename, bool is_program,
-                               copy_data_dest_cb data_dest_cb, List *attnamelist, List *options);
+                               copy_data_dest_cb data_dest_cb, List *attnamelist, List *options,
+                               List *publication_names);
 extern void EndCopyTo(CopyToState cstate);
 extern uint64 DoCopyTo(CopyToState cstate);
 extern List *CopyGetAttnums(TupleDesc tupDesc, Relation rel,
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index 0ea2df5088..6d9b6fa250 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -113,6 +113,7 @@ typedef struct LogicalRepRelation
     char        replident;        /* replica identity */
     char        relkind;        /* remote relation kind */
     Bitmapset  *attkeys;        /* Bitmap of key columns */
+    char       *pubnames;        /* publication names (comma-separated list) */
 } LogicalRepRelation;
 
 /* Type mapping info */
diff --git a/src/include/utils/acl.h b/src/include/utils/acl.h
index f8e1238fa2..eb4e5044e8 100644
--- a/src/include/utils/acl.h
+++ b/src/include/utils/acl.h
@@ -169,6 +169,7 @@ typedef struct ArrayType Acl;
 #define ACL_ALL_RIGHTS_SCHEMA        (ACL_USAGE|ACL_CREATE)
 #define ACL_ALL_RIGHTS_TABLESPACE    (ACL_CREATE)
 #define ACL_ALL_RIGHTS_TYPE            (ACL_USAGE)
+#define ACL_ALL_RIGHTS_PUBLICATION    (ACL_USAGE)
 
 /* operation codes for pg_*_aclmask */
 typedef enum
diff --git a/src/include/utils/guc_tables.h b/src/include/utils/guc_tables.h
index d5a0880678..87ddeecc6e 100644
--- a/src/include/utils/guc_tables.h
+++ b/src/include/utils/guc_tables.h
@@ -75,6 +75,7 @@ enum config_group
     REPLICATION_SENDING,
     REPLICATION_PRIMARY,
     REPLICATION_STANDBY,
+    REPLICATION_PUBLISHERS,
     REPLICATION_SUBSCRIBERS,
     QUERY_TUNING_METHOD,
     QUERY_TUNING_COST,
diff --git a/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
b/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
index e65771067e..9178e102bb 100644
--- a/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
+++ b/src/test/modules/test_copy_callbacks/test_copy_callbacks.c
@@ -38,7 +38,7 @@ test_copy_to_callback(PG_FUNCTION_ARGS)
     int64        processed;
 
     cstate = BeginCopyTo(NULL, rel, NULL, RelationGetRelid(rel), NULL, false,
-                         to_cb, NIL, NIL);
+                         to_cb, NIL, NIL, NIL);
     processed = DoCopyTo(cstate);
     EndCopyTo(cstate);
 
diff --git a/src/test/regress/expected/copy.out b/src/test/regress/expected/copy.out
index 8a8bf43fde..b6011bea0f 100644
--- a/src/test/regress/expected/copy.out
+++ b/src/test/regress/expected/copy.out
@@ -240,3 +240,55 @@ SELECT * FROM header_copytest ORDER BY a;
 (5 rows)
 
 drop table header_copytest;
+-- Filtering by publication
+-- Suppress the warning about insufficient wal_level when creating
+-- publications.
+set client_min_messages to error;
+create role regress_copy_repl_user login replication;
+create table published_copytest (i int);
+insert into published_copytest(i) select x from generate_series(1, 10) g(x);
+create publication pub1 for table published_copytest where (i >= 7);
+set publication_security to on;
+-- Test both table name and query forms of the COPY command.
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1));
+7
+8
+9
+10
+copy (select i from published_copytest) to stdout (publication_names (pub1));
+7
+8
+9
+10
+reset role;
+-- Publish some more data.
+create publication pub2 for table published_copytest where (i <= 2);
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1, pub2));
+1
+2
+7
+8
+9
+10
+reset role;
+-- If any publication has no filter, the other filters are ignored.
+create publication pub3 for table published_copytest;
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1, pub2, pub3));
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+reset role;
+reset publication_security;
+reset client_min_messages;
+drop role regress_copy_repl_user;
+drop publication pub1, pub2, pub3;
diff --git a/src/test/regress/expected/publication.out b/src/test/regress/expected/publication.out
index 427f87ea07..76a70c80d4 100644
--- a/src/test/regress/expected/publication.out
+++ b/src/test/regress/expected/publication.out
@@ -87,10 +87,10 @@ RESET client_min_messages;
 -- should be able to add schema to 'FOR TABLE' publication
 ALTER PUBLICATION testpub_fortable ADD TABLES IN SCHEMA pub_test;
 \dRp+ testpub_fortable
-                                Publication testpub_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                          Publication testpub_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_tbl1"
 Tables from schemas:
@@ -99,20 +99,20 @@ Tables from schemas:
 -- should be able to drop schema from 'FOR TABLE' publication
 ALTER PUBLICATION testpub_fortable DROP TABLES IN SCHEMA pub_test;
 \dRp+ testpub_fortable
-                                Publication testpub_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                          Publication testpub_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_tbl1"
 
 -- should be able to set schema to 'FOR TABLE' publication
 ALTER PUBLICATION testpub_fortable SET TABLES IN SCHEMA pub_test;
 \dRp+ testpub_fortable
-                                Publication testpub_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                          Publication testpub_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test"
 
@@ -123,10 +123,10 @@ CREATE PUBLICATION testpub_forschema FOR TABLES IN SCHEMA pub_test;
 CREATE PUBLICATION testpub_for_tbl_schema FOR TABLES IN SCHEMA pub_test, TABLE pub_test.testpub_nopk;
 RESET client_min_messages;
 \dRp+ testpub_for_tbl_schema
-                             Publication testpub_for_tbl_schema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                       Publication testpub_for_tbl_schema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test.testpub_nopk"
 Tables from schemas:
@@ -135,10 +135,10 @@ Tables from schemas:
 -- should be able to add a table of the same schema to the schema publication
 ALTER PUBLICATION testpub_forschema ADD TABLE pub_test.testpub_nopk;
 \dRp+ testpub_forschema
-                               Publication testpub_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test.testpub_nopk"
 Tables from schemas:
@@ -147,10 +147,10 @@ Tables from schemas:
 -- should be able to drop the table
 ALTER PUBLICATION testpub_forschema DROP TABLE pub_test.testpub_nopk;
 \dRp+ testpub_forschema
-                               Publication testpub_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test"
 
@@ -161,10 +161,10 @@ ERROR:  relation "testpub_nopk" is not part of the publication
 -- should be able to set table to schema publication
 ALTER PUBLICATION testpub_forschema SET TABLE pub_test.testpub_nopk;
 \dRp+ testpub_forschema
-                               Publication testpub_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test.testpub_nopk"
 
@@ -186,10 +186,10 @@ Publications:
     "testpub_foralltables"
 
 \dRp+ testpub_foralltables
-                              Publication testpub_foralltables
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | t          | t       | t       | f       | f         | f
+                                        Publication testpub_foralltables
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | t          | t       | t       | f       | f         | f        | 
 (1 row)
 
 DROP TABLE testpub_tbl2;
@@ -201,19 +201,19 @@ CREATE PUBLICATION testpub3 FOR TABLE testpub_tbl3;
 CREATE PUBLICATION testpub4 FOR TABLE ONLY testpub_tbl3;
 RESET client_min_messages;
 \dRp+ testpub3
-                                    Publication testpub3
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                              Publication testpub3
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_tbl3"
     "public.testpub_tbl3a"
 
 \dRp+ testpub4
-                                    Publication testpub4
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                              Publication testpub4
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_tbl3"
 
@@ -234,10 +234,10 @@ UPDATE testpub_parted1 SET a = 1;
 -- only parent is listed as being in publication, not the partition
 ALTER PUBLICATION testpub_forparted ADD TABLE testpub_parted;
 \dRp+ testpub_forparted
-                               Publication testpub_forparted
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub_forparted
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_parted"
 
@@ -252,10 +252,10 @@ ALTER TABLE testpub_parted DETACH PARTITION testpub_parted1;
 UPDATE testpub_parted1 SET a = 1;
 ALTER PUBLICATION testpub_forparted SET (publish_via_partition_root = true);
 \dRp+ testpub_forparted
-                               Publication testpub_forparted
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | t
+                                         Publication testpub_forparted
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | t        | 
 Tables:
     "public.testpub_parted"
 
@@ -284,10 +284,10 @@ SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub5 FOR TABLE testpub_rf_tbl1, testpub_rf_tbl2 WHERE (c <> 'test' AND d < 5) WITH (publish =
'insert');
 RESET client_min_messages;
 \dRp+ testpub5
-                                    Publication testpub5
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                              Publication testpub5
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "public.testpub_rf_tbl2" WHERE ((c <> 'test'::text) AND (d < 5))
@@ -300,10 +300,10 @@ Tables:
 
 ALTER PUBLICATION testpub5 ADD TABLE testpub_rf_tbl3 WHERE (e > 1000 AND e < 2000);
 \dRp+ testpub5
-                                    Publication testpub5
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                              Publication testpub5
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "public.testpub_rf_tbl2" WHERE ((c <> 'test'::text) AND (d < 5))
@@ -319,10 +319,10 @@ Publications:
 
 ALTER PUBLICATION testpub5 DROP TABLE testpub_rf_tbl2;
 \dRp+ testpub5
-                                    Publication testpub5
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                              Publication testpub5
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "public.testpub_rf_tbl3" WHERE ((e > 1000) AND (e < 2000))
@@ -330,10 +330,10 @@ Tables:
 -- remove testpub_rf_tbl1 and add testpub_rf_tbl3 again (another WHERE expression)
 ALTER PUBLICATION testpub5 SET TABLE testpub_rf_tbl3 WHERE (e > 300 AND e < 500);
 \dRp+ testpub5
-                                    Publication testpub5
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                              Publication testpub5
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl3" WHERE ((e > 300) AND (e < 500))
 
@@ -366,10 +366,10 @@ SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub_syntax1 FOR TABLE testpub_rf_tbl1, ONLY testpub_rf_tbl3 WHERE (e < 999) WITH (publish =
'insert');
 RESET client_min_messages;
 \dRp+ testpub_syntax1
-                                Publication testpub_syntax1
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                          Publication testpub_syntax1
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "public.testpub_rf_tbl3" WHERE (e < 999)
@@ -379,10 +379,10 @@ SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub_syntax2 FOR TABLE testpub_rf_tbl1, testpub_rf_schema1.testpub_rf_tbl5 WHERE (h < 999) WITH
(publish= 'insert');
 
 RESET client_min_messages;
 \dRp+ testpub_syntax2
-                                Publication testpub_syntax2
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | f         | f
+                                          Publication testpub_syntax2
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | f         | f        | 
 Tables:
     "public.testpub_rf_tbl1"
     "testpub_rf_schema1.testpub_rf_tbl5" WHERE (h < 999)
@@ -497,10 +497,10 @@ CREATE PUBLICATION testpub6 FOR TABLES IN SCHEMA testpub_rf_schema2;
 ALTER PUBLICATION testpub6 SET TABLES IN SCHEMA testpub_rf_schema2, TABLE testpub_rf_schema2.testpub_rf_tbl6 WHERE (i
<99);
 
 RESET client_min_messages;
 \dRp+ testpub6
-                                    Publication testpub6
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                              Publication testpub6
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "testpub_rf_schema2.testpub_rf_tbl6" WHERE (i < 99)
 Tables from schemas:
@@ -714,10 +714,10 @@ CREATE PUBLICATION testpub_table_ins WITH (publish = 'insert, truncate');
 RESET client_min_messages;
 ALTER PUBLICATION testpub_table_ins ADD TABLE testpub_tbl5 (a);        -- ok
 \dRp+ testpub_table_ins
-                               Publication testpub_table_ins
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | f       | f       | t         | f
+                                         Publication testpub_table_ins
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | f       | f       | t         | f        | 
 Tables:
     "public.testpub_tbl5" (a)
 
@@ -891,10 +891,10 @@ CREATE TABLE testpub_tbl_both_filters (a int, b int, c int, PRIMARY KEY (a,c));
 ALTER TABLE testpub_tbl_both_filters REPLICA IDENTITY USING INDEX testpub_tbl_both_filters_pkey;
 ALTER PUBLICATION testpub_both_filters ADD TABLE testpub_tbl_both_filters (a,c) WHERE (c != 1);
 \dRp+ testpub_both_filters
-                              Publication testpub_both_filters
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                        Publication testpub_both_filters
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "public.testpub_tbl_both_filters" (a, c) WHERE (c <> 1)
 
@@ -1099,10 +1099,10 @@ ERROR:  relation "testpub_tbl1" is already member of publication "testpub_fortbl
 CREATE PUBLICATION testpub_fortbl FOR TABLE testpub_tbl1;
 ERROR:  publication "testpub_fortbl" already exists
 \dRp+ testpub_fortbl
-                                 Publication testpub_fortbl
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                           Publication testpub_fortbl
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test.testpub_nopk"
     "public.testpub_tbl1"
@@ -1140,10 +1140,10 @@ Publications:
     "testpub_fortbl"
 
 \dRp+ testpub_default
-                                Publication testpub_default
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | f         | f
+                                          Publication testpub_default
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | f         | f        | 
 Tables:
     "pub_test.testpub_nopk"
     "public.testpub_tbl1"
@@ -1214,17 +1214,57 @@ ALTER PUBLICATION testpub4 owner to regress_publication_user2; -- fail
 ERROR:  permission denied to change owner of publication "testpub4"
 HINT:  The owner of a FOR TABLES IN SCHEMA publication must be a superuser.
 ALTER PUBLICATION testpub4 owner to regress_publication_user; -- ok
+-- Test the USAGE privilege.
+SET ROLE regress_publication_user;
+CREATE ROLE regress_publication_user4;
+-- First, check that USAGE is granted to PUBLIC by default.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+ has_publication_privilege 
+---------------------------
+ t
+(1 row)
+
+-- Revoke the USAGE privilege from PUBLIC.
+SET ROLE regress_publication_user;
+REVOKE USAGE ON PUBLICATION testpub4 FROM public;
+-- regress_publication_user4 does not have the privilege now.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+ has_publication_privilege 
+---------------------------
+ f
+(1 row)
+
+-- Grant USAGE to regress_publication_user4 explicitly.
+SET ROLE regress_publication_user;
+GRANT USAGE ON PUBLICATION testpub4 TO regress_publication_user4;
+-- regress_publication_user4 does have the privilege now.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+ has_publication_privilege 
+---------------------------
+ t
+(1 row)
+
 SET ROLE regress_publication_user;
 DROP PUBLICATION testpub4;
 DROP ROLE regress_publication_user3;
+DROP ROLE regress_publication_user4;
 REVOKE CREATE ON DATABASE regression FROM regress_publication_user2;
 DROP TABLE testpub_parted;
 DROP TABLE testpub_tbl1;
 \dRp+ testpub_default
-                                Publication testpub_default
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | f         | f
+                                          Publication testpub_default
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | f         | f        | 
 (1 row)
 
 -- fail - must be owner of publication
@@ -1263,19 +1303,19 @@ CREATE TABLE "CURRENT_SCHEMA"."CURRENT_SCHEMA"(id int);
 SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub1_forschema FOR TABLES IN SCHEMA pub_test1;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
 CREATE PUBLICATION testpub2_forschema FOR TABLES IN SCHEMA pub_test1, pub_test2, pub_test3;
 \dRp+ testpub2_forschema
-                               Publication testpub2_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub2_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1289,44 +1329,44 @@ CREATE PUBLICATION testpub6_forschema FOR TABLES IN SCHEMA "CURRENT_SCHEMA", CUR
 CREATE PUBLICATION testpub_fortable FOR TABLE "CURRENT_SCHEMA"."CURRENT_SCHEMA";
 RESET client_min_messages;
 \dRp+ testpub3_forschema
-                               Publication testpub3_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub3_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "public"
 
 \dRp+ testpub4_forschema
-                               Publication testpub4_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub4_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "CURRENT_SCHEMA"
 
 \dRp+ testpub5_forschema
-                               Publication testpub5_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub5_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "CURRENT_SCHEMA"
     "public"
 
 \dRp+ testpub6_forschema
-                               Publication testpub6_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub6_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "CURRENT_SCHEMA"
     "public"
 
 \dRp+ testpub_fortable
-                                Publication testpub_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                          Publication testpub_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "CURRENT_SCHEMA.CURRENT_SCHEMA"
 
@@ -1360,10 +1400,10 @@ ERROR:  schema "testpub_view" does not exist
 -- dropping the schema should reflect the change in publication
 DROP SCHEMA pub_test3;
 \dRp+ testpub2_forschema
-                               Publication testpub2_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub2_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1371,20 +1411,20 @@ Tables from schemas:
 -- renaming the schema should reflect the change in publication
 ALTER SCHEMA pub_test1 RENAME to pub_test1_renamed;
 \dRp+ testpub2_forschema
-                               Publication testpub2_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub2_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1_renamed"
     "pub_test2"
 
 ALTER SCHEMA pub_test1_renamed RENAME to pub_test1;
 \dRp+ testpub2_forschema
-                               Publication testpub2_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub2_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1392,10 +1432,10 @@ Tables from schemas:
 -- alter publication add schema
 ALTER PUBLICATION testpub1_forschema ADD TABLES IN SCHEMA pub_test2;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1404,10 +1444,10 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema ADD TABLES IN SCHEMA non_existent_schema;
 ERROR:  schema "non_existent_schema" does not exist
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1416,10 +1456,10 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema ADD TABLES IN SCHEMA pub_test1;
 ERROR:  schema "pub_test1" is already member of publication "testpub1_forschema"
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1427,10 +1467,10 @@ Tables from schemas:
 -- alter publication drop schema
 ALTER PUBLICATION testpub1_forschema DROP TABLES IN SCHEMA pub_test2;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
@@ -1438,10 +1478,10 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema DROP TABLES IN SCHEMA pub_test2;
 ERROR:  tables from schema "pub_test2" are not part of the publication
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
@@ -1449,29 +1489,29 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema DROP TABLES IN SCHEMA non_existent_schema;
 ERROR:  schema "non_existent_schema" does not exist
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
 -- drop all schemas
 ALTER PUBLICATION testpub1_forschema DROP TABLES IN SCHEMA pub_test1;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 (1 row)
 
 -- alter publication set multiple schema
 ALTER PUBLICATION testpub1_forschema SET TABLES IN SCHEMA pub_test1, pub_test2;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1480,10 +1520,10 @@ Tables from schemas:
 ALTER PUBLICATION testpub1_forschema SET TABLES IN SCHEMA non_existent_schema;
 ERROR:  schema "non_existent_schema" does not exist
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
     "pub_test2"
@@ -1492,10 +1532,10 @@ Tables from schemas:
 -- removing the duplicate schemas
 ALTER PUBLICATION testpub1_forschema SET TABLES IN SCHEMA pub_test1, pub_test1;
 \dRp+ testpub1_forschema
-                               Publication testpub1_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub1_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
@@ -1574,18 +1614,18 @@ SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub3_forschema;
 RESET client_min_messages;
 \dRp+ testpub3_forschema
-                               Publication testpub3_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub3_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 (1 row)
 
 ALTER PUBLICATION testpub3_forschema SET TABLES IN SCHEMA pub_test1;
 \dRp+ testpub3_forschema
-                               Publication testpub3_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                         Publication testpub3_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables from schemas:
     "pub_test1"
 
@@ -1595,20 +1635,20 @@ CREATE PUBLICATION testpub_forschema_fortable FOR TABLES IN SCHEMA pub_test1, TA
 CREATE PUBLICATION testpub_fortable_forschema FOR TABLE pub_test2.tbl1, TABLES IN SCHEMA pub_test1;
 RESET client_min_messages;
 \dRp+ testpub_forschema_fortable
-                           Publication testpub_forschema_fortable
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                     Publication testpub_forschema_fortable
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test2.tbl1"
 Tables from schemas:
     "pub_test1"
 
 \dRp+ testpub_fortable_forschema
-                           Publication testpub_fortable_forschema
-          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root 
---------------------------+------------+---------+---------+---------+-----------+----------
- regress_publication_user | f          | t       | t       | t       | t         | f
+                                     Publication testpub_fortable_forschema
+          Owner           | All tables | Inserts | Updates | Deletes | Truncates | Via root | Access privileges 
+--------------------------+------------+---------+---------+---------+-----------+----------+-------------------
+ regress_publication_user | f          | t       | t       | t       | t         | f        | 
 Tables:
     "pub_test2.tbl1"
 Tables from schemas:
diff --git a/src/test/regress/sql/copy.sql b/src/test/regress/sql/copy.sql
index f9da7b1508..4174823cff 100644
--- a/src/test/regress/sql/copy.sql
+++ b/src/test/regress/sql/copy.sql
@@ -268,3 +268,39 @@ a    c    b
 
 SELECT * FROM header_copytest ORDER BY a;
 drop table header_copytest;
+
+-- Filtering by publication
+
+-- Suppress the warning about insufficient wal_level when creating
+-- publications.
+set client_min_messages to error;
+
+create role regress_copy_repl_user login replication;
+create table published_copytest (i int);
+insert into published_copytest(i) select x from generate_series(1, 10) g(x);
+create publication pub1 for table published_copytest where (i >= 7);
+
+set publication_security to on;
+
+-- Test both table name and query forms of the COPY command.
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1));
+copy (select i from published_copytest) to stdout (publication_names (pub1));
+reset role;
+
+-- Publish some more data.
+create publication pub2 for table published_copytest where (i <= 2);
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1, pub2));
+reset role;
+
+-- If any publication has no filter, the other filters are ignored.
+create publication pub3 for table published_copytest;
+set role regress_copy_repl_user;
+copy published_copytest to stdout (publication_names (pub1, pub2, pub3));
+reset role;
+
+reset publication_security;
+reset client_min_messages;
+drop role regress_copy_repl_user;
+drop publication pub1, pub2, pub3;
diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql
index a47c5939d5..303870a1e9 100644
--- a/src/test/regress/sql/publication.sql
+++ b/src/test/regress/sql/publication.sql
@@ -808,9 +808,37 @@ SET ROLE regress_publication_user3;
 ALTER PUBLICATION testpub4 owner to regress_publication_user2; -- fail
 ALTER PUBLICATION testpub4 owner to regress_publication_user; -- ok
 
+-- Test the USAGE privilege.
+SET ROLE regress_publication_user;
+CREATE ROLE regress_publication_user4;
+-- First, check that USAGE is granted to PUBLIC by default.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+
+-- Revoke the USAGE privilege from PUBLIC.
+SET ROLE regress_publication_user;
+REVOKE USAGE ON PUBLICATION testpub4 FROM public;
+-- regress_publication_user4 does not have the privilege now.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+
+-- Grant USAGE to regress_publication_user4 explicitly.
+SET ROLE regress_publication_user;
+GRANT USAGE ON PUBLICATION testpub4 TO regress_publication_user4;
+-- regress_publication_user4 does have the privilege now.
+SET ROLE regress_publication_user4;
+SELECT has_publication_privilege(p.oid, 'usage')
+FROM pg_catalog.pg_publication p
+WHERE p.pubname='testpub4';
+
 SET ROLE regress_publication_user;
 DROP PUBLICATION testpub4;
 DROP ROLE regress_publication_user3;
+DROP ROLE regress_publication_user4;
 
 REVOKE CREATE ON DATABASE regression FROM regress_publication_user2;
 
diff --git a/src/test/subscription/t/027_nosuperuser.pl b/src/test/subscription/t/027_nosuperuser.pl
index 59192dbe2f..31e94514c1 100644
--- a/src/test/subscription/t/027_nosuperuser.pl
+++ b/src/test/subscription/t/027_nosuperuser.pl
@@ -7,8 +7,10 @@ use warnings;
 use PostgreSQL::Test::Cluster;
 use Test::More;
 
-my ($node_publisher, $node_subscriber, $publisher_connstr, $result, $offset);
+my ($node_publisher, $node_subscriber, $publisher_connstr, $result, $offset,
+    $offset_pub);
 $offset = 0;
+$offset_pub = 0;
 
 sub publish_insert
 {
@@ -103,7 +105,8 @@ $node_publisher->init(allows_streaming => 'logical');
 $node_subscriber->init;
 $node_publisher->start;
 $node_subscriber->start;
-$publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+# Non-super user, so that we can test publication privileges.
+$publisher_connstr = $node_publisher->connstr . ' dbname=postgres user=regress_alice';
 my %remainder_a = (
     publisher  => 0,
     subscriber => 1);
@@ -141,6 +144,8 @@ for my $node ($node_publisher, $node_subscriber)
 }
 $node_publisher->safe_psql(
     'postgres', qq(
+ALTER ROLE regress_alice REPLICATION;
+
 SET SESSION AUTHORIZATION regress_alice;
 
 CREATE PUBLICATION alice
@@ -316,4 +321,53 @@ expect_replication("alice.unpartitioned", 2, 23, 25,
     "nosuperuser nobypassrls table owner can replicate delete into unpartitioned despite rls"
 );
 
+# Test publication permissions.
+$node_publisher->append_conf(
+    'postgresql.conf',
+    qq[
+publication_security = on
+]);
+$node_publisher->restart;
+
+# First, make sure that the user specified in the subscription is not able to
+# access the data, then do some changes. (By deleting everything we make the
+# following checks simpler.)
+$node_publisher->safe_psql(
+    'postgres', qq(
+REVOKE USAGE ON PUBLICATION alice FROM PUBLIC;
+REVOKE USAGE ON PUBLICATION alice FROM regress_alice;
+ALTER DATABASE postgres SET publication_security TO on;
+
+DELETE FROM alice.unpartitioned;
+));
+# Missing permission should cause error.
+expect_failure("alice.unpartitioned", 2, 23, 25,
+               qr/ERROR: ( [A-Z0-9]+:)? permission denied for publication alice/msi, 0);
+# Check that the missing privilege makes table synchronization fail too.
+$node_subscriber->safe_psql(
+    'postgres', qq(
+SET SESSION AUTHORIZATION regress_admin;
+DROP SUBSCRIPTION admin_sub;
+TRUNCATE TABLE alice.unpartitioned;
+CREATE SUBSCRIPTION admin_sub CONNECTION '$publisher_connstr' PUBLICATION alice;
+));
+# Note that expect_failure() does not wait for the end of the synchronization,
+# so if there was any data on publisher side and if it found its way to the
+# subscriber, the function might still see an empty table. So we only rely on
+# the function to check the error message.
+expect_failure("alice.unpartitioned", 0, '', '',
+               qr/ERROR: ( [A-Z0-9]+:)? permission denied for publication alice/msi, 0);
+# Restore the privilege on the publication.
+$node_publisher->safe_psql(
+    'postgres', qq(
+GRANT USAGE ON PUBLICATION alice TO regress_alice;
+));
+# Wait for synchronization to complete.
+$node_subscriber->wait_for_subscription_sync;
+# The replication should work again now.
+publish_insert("alice.unpartitioned", 1);
+expect_replication("alice.unpartitioned", 1, 1, 1,
+   "unpartitioned is replicated as soon as regress_alic has permissions on alice publication"
+);
+
 done_testing();
-- 
2.31.1


Re: Privileges on PUBLICATION

From
Peter Eisentraut
Date:
On 14.03.23 19:30, Gregory Stark (as CFM) wrote:
> FYI this looks like it needs a rebase due to a conflict in copy.c and
> an offset in pgoutput.c.
> 
> Is there anything specific that still needs review or do you think
> you've handled all Peter's concerns? In particular, is there "a
> comprehensive description of what it is trying to do"? :)

The latest versions of the patch have pretty much addressed my initial 
comments.  The patch is structured and explained better now.  Most 
extraneous or incomplete changes have been addressed.

The problem now is that it's still a quite complicated patch that 
introduces a security feature.  It still touches a number of subsystems 
on different levels of abstraction.  This functionality is not of the 
kind, "if you don't use it it won't affect you", since it effectively 
pokes holes into the existing privileges checking in order to allow 
publication privileges checking to override it in some cases.  It will 
take significant effort to do a complete analysis and testing on whether 
it is secure and robust.  I don't think I will have time for that, and I 
don't think anyone will want to commit something like this at the last 
moment.

We have already taken a number of things from earlier patches and 
committed them separately as refactorings.  I don't see anything in the 
current patch anymore that we might want to take independently like that.

So in summary I think it would be best to keep this patch around for PG17.