From 7c4595b77c9caa09b5a043aeb322e1f9230cdba4 Mon Sep 17 00:00:00 2001
From: "David G. Johnston" <David.G.Johnston@Gmail.com>
Date: Thu, 8 Sep 2022 15:38:02 +0000
Subject: [PATCH] Implement psql meta-commands \dr[rgu][S]

Role graphs, system views and meta-commands.
---
 doc/src/sgml/ref/psql-ref.sgml            |  20 ++
 doc/src/sgml/system-views.sgml            | 261 +++++++++++++++++++++
 src/backend/catalog/pg_role_graph.plpgsql | 274 ++++++++++++++++++++++
 src/backend/catalog/system_views.sql      | 259 ++++++++++++++++++++
 src/bin/psql/command.c                    |  12 +
 src/bin/psql/describe.c                   | 108 +++++++++
 src/bin/psql/describe.h                   |   9 +
 7 files changed, 943 insertions(+)
 create mode 100644 src/backend/catalog/pg_role_graph.plpgsql

diff --git a/doc/src/sgml/ref/psql-ref.sgml b/doc/src/sgml/ref/psql-ref.sgml
index 186f8c506a..6e24c67eb8 100644
--- a/doc/src/sgml/ref/psql-ref.sgml
+++ b/doc/src/sgml/ref/psql-ref.sgml
@@ -1863,6 +1863,26 @@ testdb=&gt;
         </listitem>
       </varlistentry>
 
+      <varlistentry>
+        <term><literal>\dr[rgu][S] [ <link linkend="app-psql-patterns"><replaceable class="parameter">pattern</replaceable></link> ]</literal></term>
+        <listitem>
+        <para>
+        Present role memberships, recursively.
+        </para>
+        <para>
+        The mnemonic for the base command is: describe the role-graph for (r)oles, (g)groups
+        , or (u)sers.  The traditional <literal>S</literal> suffix controls whether the default (group) roles
+        (i.e., those prefixed with <literal>pg_</literal>) are displayed.
+        If <replaceable class="parameter">pattern</replaceable> is specified,
+        only those roles whose names match the pattern are listed.
+        </para>
+        <para>
+        Please see the description for the <link linkend="view-pg-role-graph">pg_role_graph</link> system view for details
+        regarding the interpretation of the information presented herein.
+        </para>
+        </listitem>
+      </varlistentry>
+
       <varlistentry>
         <term><literal>\dRp[+] [ <link linkend="app-psql-patterns"><replaceable class="parameter">pattern</replaceable></link> ]</literal></term>
         <listitem>
diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 44aa70a031..3887bebc2f 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -2464,6 +2464,267 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
   </table>
  </sect1>
 
+ <sect1 id="view-pg-role-graph">
+  <title><structname>pg_roles</structname></title>
+
+  <indexterm zone="view-pg-role-graph">
+   <primary>pg_role_graph</primary>
+  </indexterm>
+
+  <para>
+   The view <structname>pg_role_graph</structname> provides, for each role in
+   the system, those roles it is a member of and those that are a member of it.
+   This includes both direct and indirect membership grants that can be traversed
+   via SET ROLE.  To aid in comprehension of this graph of data it is divided
+   both along the memberof/member dimension and whether the role itself is a
+   user or group role, as determined by holding the explicit login attribute.
+   Additionally, WITH GRANT OPTION is handled via a separate Administration column.
+  </para>
+
+  <para>
+    (While the concepts of <quote>users</quote> and <quote>groups</quote> have been
+    unified within the formal syntax grammar the two concepts remain valid
+    when it comes to the practical administration of a system.  These views segregate
+    information by groups and users when appropriate to aid in comprehension.  A group
+    is simply any role that is not a user, while a user is a role that explicitly has
+    has the login attribute.)
+  </para>
+
+  <para>
+    The view presents, for a named role, grant information using a somewhat condensed coding scheme.
+    The coding scheme is the name of some other role in the system, quoted if necessary, and then either the word
+    "from" or "via".  A "from" means that the grant in question has been directly recorded in the system catalog.
+    A "via" means the grant results from some chain of grants.  In this situation there may be an optional bracketed
+    suffix (e.g., [+2]) indicating how many additional indirect grants are present.  In both cases the grantor owning
+    the grant is reported.  In the "from" case the comma-separated list of roles following the "from" are these grantors.
+    In the "via" case the grantor is separated from the other role with a forward slash.  Additionally, if multiple
+    paths result in the same grant, the individual preceding paths will all be listed with newlines separating them.
+  </para>
+
+  <para>
+    There is an Administration column that shows, including indirectly, <literal>WITH ADMIN OPTION</literal> grants.
+    Roles having this on the named role are denoted by a prefix of "by" combined with the grant specification
+    described above.  A prefix of "of" is used if the named role is the one that is able to change the membership
+    of the role in the grant specification. This information is a duplicated subset of the grant specifications
+    recorded in the remaining four columns.
+  </para>
+
+  <para>
+    The remaining four columns present the grant specifications related to the named role but divided among the
+    two possible options in the role type (Users, Groups) and direction (Member of, Member) dimensions.
+    The use of separate columns communicates the same information that "of" and "by" communicate in the
+    Administration column and so those prefixes are omitted here in the interest of space.
+  </para>
+
+  <para>
+<programlisting>
+GOOD ENOUGH FOR REVIEW - WILL BE UPDATED              List of role graphs
+Role name | Administration                 |    Member of Groups         | Group Members      |    User Members        |  Member of Users  
+-----------+--------------------------------+-----------------------------+--------------------+------------------------+-------------------
+usr1      |                                | grp1 from vagrant          +|                    | usr1a from vagrant     | 
+          |                                | grp2 from usr2, vagrant     |                    |                        | 
+usr1a     |                                | "group 3" from vagrant     +|                    |                        | usr1 from vagrant
+          |                                | grp1 via usr1/vagrant      +|                    |                        | 
+          |                                | grp2 via usr1/usr2         +|                    |                        | 
+          |                                |          usr1/vagrant       |                    |                        |                        
+usr5      | of grp5c via grp5b/vagrant[+1]+| grp5a from vagrant         +|                    |                        | 
+          | of grp5d via grp5c/vagrant[+2] | grp5b via grp5a/vagrant    +|                    |                        | 
+          |                                | grp5c via grp5b/vagrant[+1]+|                    |                        | 
+          |                                | grp5d via grp5c/vagrant[+2] |                    |                        | 
+grp5b     | of grp5c from vagrant         +| grp5c from vagrant         +| grp5a from vagrant | usr5 via grp5a/vagrant | 
+          | of grp5d via grp5c/vagrant     | grp5d via grp5c/vagrant     |                    |                        | 
+</programlisting> 
+    User usr1 has received membership in grp2 from both usr2 and vagrant; and user usr1a is (unusually) a member of it.
+    Since usr1a is a member of usr1 the two paths to membership in grp2 also apply to them.
+    grp5b is granted with admin option of grp5c by vagrant, thus all member roles of grp5b, like usr5, can assume this
+    capability, which extends indirectly to grp5d of which grp5c is a member.  Thus usr5, a member of grp5b indirectly
+    via its direct membership in grp5a, is shown as administrator of grp5c and grp5d two and three steps removed respectively.
+  </para>
+
+  <para>
+    The graph is built using the traversal logic in the pg_role_relationship system view.
+  </para>
+
+  <table>
+   <title><structname>pg_role_graph</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rolname</structfield> <type>name</type>
+      </para>
+      <para>
+       Role name
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>administration</structfield> <type>text</type>
+      </para>
+      <para>
+       The listed roles can either be administered "by" this role, or this
+       is an administer "of" the listed roles.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>memberof_users</structfield> <type>text</type>
+      </para>
+      <para>
+       User role that this role is a member of.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>memberof_groups</structfield> <type>text</type>
+      </para>
+      <para>
+       Group roles that this role is a member of.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>member_users</structfield> <type>text</type>
+      </para>
+      <para>
+       User roles that are members of this role.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>member_groups</structfield> <type>text</type>
+      </para>
+      <para>
+       Group roles that are members of this role.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>role_type</structfield> <type>text</type>
+      </para>
+      <para>
+       Report "Group" or "User" as appropriate for this role.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>oid</structfield> <type>oid</type>
+      </para>
+      <para>
+       ID of role.
+      </para></entry>
+     </row>
+
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
+ <sect1 id="view-pg-role-relationship">
+  <title><structname>pg_role_relationship</structname></title>
+
+  <indexterm zone="view-pg-role-relationship">
+   <primary>pg_role_relationship</primary>
+  </indexterm>
+
+  <para>
+   The view <structname>pg_role_relationship</structname> produces one row
+   for every role reachable by another role.
+  </para>
+
+  <table>
+   <title><structname>pg_role_relationship</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>leaf_node</structfield> <type>oid</type>
+      </para>
+      <para>
+       The starting role for the upward-only (member of) graph traversal.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>group_node</structfield> <type>oid</type>
+      </para>
+      <para>
+       The group role that our leaf role is a member of, possibly indirectly
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>grantor</structfield> <type>oid</type>
+      </para>
+      <para>
+       The role responsible for granting the direct grant this membership derives from.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>level</structfield> <type>int4</type>
+      </para>
+      <para>
+       The number of memberships traversed to get to this one. 1 means a direct grant.
+       0 is the starting point where a role is treated as a member of itself.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>via</structfield> <type>oid[]</type>
+      </para>
+      <para>
+       The inclusive path of role OIDs from the leaf_node to this group_node.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>got_auth</structfield> <type>bool</type>
+      </para>
+      <para>
+       True if any of the grants in the via path are granted with admin option.
+      </para></entry>
+     </row>
+
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
  <sect1 id="view-pg-roles">
   <title><structname>pg_roles</structname></title>
 
diff --git a/src/backend/catalog/pg_role_graph.plpgsql b/src/backend/catalog/pg_role_graph.plpgsql
new file mode 100644
index 0000000000..3c89991b6f
--- /dev/null
+++ b/src/backend/catalog/pg_role_graph.plpgsql
@@ -0,0 +1,274 @@
+-- SQL code generator for the pg_role_graph and pg_role_relation system views recorded in system_views.sql
+DROP VIEW IF EXISTS role_graph;
+DROP VIEW IF EXISTS role_relationship;
+
+DO $$
+DECLARE
+    create_view_rr_part text;
+    role_relationship_cte text;
+    main_rr_select text;
+
+    create_view_rg_part text;
+    role_graph_detail_cte text;
+    role_graph_join_template text;
+    role_graph text;
+    role_graph_lead text;
+    role_graph_foot text;
+    role_graph_template text;
+    role_graph_prefix text;
+    role_graph_having text;
+    role_graph_core text;
+    main_rg_select text;
+    leaf_col_name text := 'leaf_node';
+    group_col_name text := 'group_node';
+    col_member_user text := 'member_users';
+    col_member_group text := 'member_groups';
+    col_memberof_user text := 'memberof_users';
+    col_memberof_group text := 'memberof_groups';
+BEGIN
+create_view_rr_part := E'CREATE VIEW role_relationship AS\n';
+
+role_relationship_cte := format(E'
+WITH RECURSIVE cte_role_relationship AS (
+    -- Select all known roles on the system; we consider every role as a potential
+    -- leaf node for a tree.  Recursively ascend through the tree producing a
+    -- new row relating every group role encountered to the leaf role that we started from
+    SELECT
+        r.oid AS %1$s,  -- leaf node
+        r.oid::oid AS %2$s, -- The leaf is a member of itself to kick-start tree ascent
+        NULL::oid AS grantor, -- But no one grants this implicit membership
+        0 AS level,
+        ARRAY[]::oid[] AS via,
+        FALSE AS got_auth
+    FROM
+        pg_catalog.pg_roles AS r
+    UNION ALL -- The system prevents cycles from being created so no special logic required here to avoid infinite recursion
+    SELECT
+        a.%1$s, -- I am still the leaf
+        m.roleid AS %2$s, -- And groups that I am a member of now get evaluated as being potential members in their own right
+        m.grantor, -- who is responsible for this grant
+        a.level + 1,
+        a.via || m.roleid, -- add here to how I got here (TODO: don''t include the current node)
+        a.got_auth OR m.admin_option -- does this sub-tree enable me to get admin option and thus add others to it?
+    FROM
+        cte_role_relationship AS a
+        JOIN pg_catalog.pg_auth_members AS m ON (m.member = a.%2$s) -- The last iteration''s group node now becomes a member
+)\n', leaf_col_name, group_col_name);
+
+main_rr_select := 'SELECT * FROM cte_role_relationship;';
+
+EXECUTE create_view_rr_part || role_relationship_cte || main_rr_select;
+
+create_view_rg_part := E'CREATE VIEW role_graph AS\n';
+
+role_graph_detail_cte := E'WITH role_graph_detail AS (';
+role_graph_detail_cte := role_graph_detail_cte || format(E'
+SELECT
+    r.oid,
+    r.rolname,
+    CASE WHEN r.rolcanlogin THEN
+        ''User''
+    ELSE
+        ''Group''
+    END AS role_type,
+    %1$s,
+    %2$s,
+    %3$s,
+    %4$s,
+    r.rolsuper,
+    r.rolcreaterole
+FROM
+    pg_catalog.pg_roles AS r
+', col_memberof_user, col_memberof_group, col_member_user, col_member_group);
+
+role_graph_join_template := E'
+    JOIN LATERAL (
+        -- Compute an array of distinct pg_role.oid values that a given leaf can reach.
+        -- We subdivide the result space into four quadrants
+        -- 1) Our leaf is a member whose containers are users
+        -- 2) Our leaf is a member whose containers are groups
+        -- 3) Our leaf is a container whose members are users
+        -- 4) Our leaf is a container whose members are groups
+        -- Input 1 is where our leaf oid resides, which depends on whether it is a member or container
+        -- Input 2 is the other thing
+        SELECT
+            array_agg(DISTINCT a.%2$s ORDER BY a.%2$s) -- output the other thing
+        FROM
+            role_relationship AS a
+            JOIN pg_catalog.pg_roles AS u ON u.oid = a.%2$s -- get the other thing''s rolcanlogin
+                AND %4$s u.rolcanlogin -- to decide whether it meets the users/group criteria for the quadrant
+        WHERE
+            a.%1$s = r.oid -- lateral result from the graph scoped to our leaf only
+            AND r.oid <> a.%2$s -- but exclude the implicit level 0 self-membership entry
+    ) AS %5$s (%3$s) ON TRUE';
+
+role_graph_detail_cte := role_graph_detail_cte || format(role_graph_join_template, leaf_col_name, group_col_name, col_memberof_user, '', 'mou'); -- 1
+role_graph_detail_cte := role_graph_detail_cte || format(role_graph_join_template, leaf_col_name, group_col_name, col_memberof_group, 'not', 'mog'); -- 2
+role_graph_detail_cte := role_graph_detail_cte || format(role_graph_join_template, group_col_name, leaf_col_name, col_member_user, '', 'mu'); -- 3
+role_graph_detail_cte := role_graph_detail_cte || format(role_graph_join_template, group_col_name, leaf_col_name, col_member_group, 'not', 'mg'); -- 4
+role_graph_detail_cte := role_graph_detail_cte || E'\n)\n';
+
+-- The next two fragments deal with considering the relationships strictly within the context
+-- of whether with admin option is possible.  The base query to compute the relationships
+-- has different presentation requirements when use in this context and so these can
+-- be appended to the in-query string builder in the administration case but left
+-- as the empty string in the general case.
+role_graph_prefix := E'
+    -- since the administration column considered both the thing holding with admin option
+    -- and the thing upon which with admin option is held we prefix the membership string
+    -- to indicate which direction the relationship is going.
+    CASE WHEN bool_or(grant_instance.got_auth)
+        THEN ''%1$s ''
+        ELSE ''''
+    END ||
+';
+
+role_graph_having := E'
+-- we only care about subtree relationships that result in a with admin option
+-- being possible.
+HAVING
+    bool_or(grant_instance.got_auth)
+';
+
+-- Now the general template that will compute a newline separated listing of membership relationships
+-- including grantor information.
+role_graph_template := E'
+SELECT
+    -- placeholder for the optional administration prefix (with admin option)
+    %4$s
+        CASE WHEN cardinality(grant_instance.via) > 1
+                -- Since we have got here via intermediate memberships we indicate that this membership is indirect
+                -- In the case of a single intermediate step we can simply show the indirect and enabling grant explicitly
+                -- In the case of multiple intermediate steps we indicate how many additional steps are required to get
+                --   to the enabling grant.
+                -- Multiple subtrees can introduce the same membership so we aggregate; specifically newline with padding.
+                THEN format(''%%I via %%s'',
+                        other_role.rolname,
+                        string_agg(
+                            quote_ident(ancestor_role.rolname) ||
+                                E''/'' ||  -- used for consistency with object grant representation of grantor
+                                quote_ident(grant_role.rolname) ||
+                                CASE WHEN grant_instance.level > 2
+                                    THEN ''[+'' || grant_instance.level - 2 || '']''
+                                    ELSE ''''
+                                END,
+                            E''\\n'' || repeat('' '', length(other_role.rolname) + 5) -- pad after the newline so each grantor starts at the same place
+                        )
+                     )
+                -- Direct grants are simply represented as the membership from grantor
+                -- Nothing comes before the membership as the column in which the link is presented determines
+                -- the relationship direction.
+                ELSE format(''%%I from %%s'',
+                        other_role.rolname,
+                        string_agg(quote_ident(grant_role.rolname), '', '')
+                     )
+        END
+FROM
+    -- explode the unique arrays we just built, merge in some contextual data, then re-assemble as strings
+    unnest(leaf_role.%1$s) AS other
+
+    JOIN pg_catalog.pg_roles AS other_role ON other_role.oid = other.oid
+
+    -- Pick the row(s) from the graph based upon whether the leaf is supposed to be the container or member
+
+    -- Each unnested row in other can match mulitple rows in role_relationship but basically
+    -- we want to report each other.oid once (newline separation) and aggregate any repeating here
+    -- separately (nested newlines with indents for other)
+    -- XXX: I think this nested description is right - but this query tries to combine them in a single group by...
+    JOIN role_relationship AS grant_instance ON grant_instance.%2$s = leaf_role.oid
+        AND grant_instance.%3$s = other.oid
+
+    JOIN pg_catalog.pg_roles AS grant_role ON grant_role.oid = grant_instance.grantor
+
+    -- Since the tip of the subtree (which is the rr row we are working on) is placed onto the via array the
+    -- second-to-last entry designates how we got here
+    -- If this doesn''t result in a match we just assume we got here via direct grant which triggers the first
+    -- case branch output.
+    LEFT JOIN pg_catalog.pg_roles AS ancestor_role ON ancestor_role.oid = grant_instance.via[cardinality(grant_instance.via) - 1]
+
+GROUP BY
+    other_role.rolname, -- Only show each membership once...
+    grant_instance.via -- XXX: not totally sure on this; also a bit concerned about loops.
+
+-- and we introduce a HAVING clause if we want to limit ourselves to only entries involving with admin option
+%5$s
+';
+
+role_graph_lead := E'
+, cte_role_graph AS (
+SELECT
+    leaf_role.oid,
+    leaf_role.role_type,
+    leaf_role.rolname,
+    leaf_role.rolsuper,
+';
+
+role_graph_core := format(E'
+array_to_string(ARRAY(
+    SELECT
+        *
+    FROM (
+        VALUES (''Superuser'')) vals (v)
+    WHERE
+        leaf_role.rolsuper
+    UNION ALL
+    SELECT
+        *
+    FROM (
+        VALUES (''Create Role'')) vals (v)
+    WHERE
+        leaf_role.rolcreaterole
+    UNION ALL
+    %1$s
+    UNION ALL
+    %3$s
+    UNION ALL
+    %5$s
+    UNION ALL
+    %7$s
+), E''\\n'') AS administration,
+array_to_string(ARRAY(%2$s), E''\\n'') AS %9$s,
+array_to_string(ARRAY(%4$s), E''\\n'') AS %10$s,
+array_to_string(ARRAY(%6$s), E''\\n'') AS %11$s,
+array_to_string(ARRAY(%8$s), E''\\n'') AS %12$s
+',
+format(role_graph_template, col_memberof_user, leaf_col_name, group_col_name, format(role_graph_prefix, 'of'), role_graph_having),
+format(role_graph_template, col_memberof_user, leaf_col_name, group_col_name, '', ''),
+format(role_graph_template, col_memberof_group, leaf_col_name, group_col_name, format(role_graph_prefix, 'of'), role_graph_having),
+format(role_graph_template, col_memberof_group, leaf_col_name, group_col_name, '', ''),
+format(role_graph_template, col_member_user, group_col_name, leaf_col_name, format(role_graph_prefix, 'by'), role_graph_having),
+format(role_graph_template, col_member_user, group_col_name, leaf_col_name, '', ''),
+format(role_graph_template, col_member_group, group_col_name, leaf_col_name, format(role_graph_prefix, 'by'), role_graph_having),
+format(role_graph_template, col_member_group, group_col_name, leaf_col_name, '', ''),
+col_memberof_user,
+col_memberof_group,
+col_member_user,
+col_member_group
+);
+
+role_graph_foot := E'FROM role_graph_detail AS leaf_role\n)\n';
+
+role_graph := role_graph_lead || role_graph_core || role_graph_foot;
+
+main_rg_select := format(E'
+SELECT
+    rolname, administration, %1$s, %2$s, %3$s, %4$s,
+    role_type, oid,
+    row_number() OVER (ORDER BY
+        role_type,
+        CASE WHEN rolsuper THEN oid::integer END ASC nulls LAST,
+        CASE WHEN rolname ~ ''pg_'' THEN 0 ELSE 1 END,
+        rolname) AS seq
+FROM
+    cte_role_graph
+ORDER BY
+    seq;
+',col_memberof_user, col_memberof_group, col_member_user, col_member_group);
+
+EXECUTE create_view_rg_part || role_graph_detail_cte || role_graph || main_rg_select;
+
+END;
+$$;
+
+SELECT * FROM role_relationship;-- LIMIT 1;
+SELECT * FROM role_graph;-- LIMIT 1;
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 55f7ec79e0..adaea51148 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1310,3 +1310,262 @@ CREATE VIEW pg_stat_subscription_stats AS
         ss.stats_reset
     FROM pg_subscription as s,
          pg_stat_get_subscription_stats(s.oid) as ss;
+
+/* Built using a plpgsql dynamic SQL generator, pg_dump result copied here with name change. */
+/* See: pg_role_graph.plpgsql */
+CREATE VIEW pg_role_relationship AS
+ WITH RECURSIVE cte_role_relationship AS (
+         SELECT r.oid AS leaf_node,
+            r.oid AS group_node,
+            NULL::oid AS grantor,
+            0 AS level,
+            ARRAY[]::oid[] AS via,
+            false AS got_auth
+           FROM pg_roles r
+        UNION ALL
+         SELECT a.leaf_node,
+            m.roleid AS group_node,
+            m.grantor,
+            (a.level + 1),
+            (a.via || m.roleid),
+            (a.got_auth OR m.admin_option)
+           FROM (cte_role_relationship a
+             JOIN pg_auth_members m ON ((m.member = a.group_node)))
+        )
+ SELECT cte_role_relationship.leaf_node,
+    cte_role_relationship.group_node,
+    cte_role_relationship.grantor,
+    cte_role_relationship.level,
+    cte_role_relationship.via,
+    cte_role_relationship.got_auth
+   FROM cte_role_relationship;
+
+CREATE VIEW pg_role_graph AS
+ WITH role_graph_detail AS (
+         SELECT r.oid,
+            r.rolname,
+                CASE
+                    WHEN r.rolcanlogin THEN 'User'::text
+                    ELSE 'Group'::text
+                END AS role_type,
+            mou.memberof_users,
+            mog.memberof_groups,
+            mu.member_users,
+            mg.member_groups,
+            r.rolsuper,
+            r.rolcreaterole
+           FROM ((((pg_roles r
+             JOIN LATERAL ( SELECT array_agg(DISTINCT a.group_node ORDER BY a.group_node) AS array_agg
+                   FROM (pg_role_relationship a
+                     JOIN pg_roles u ON (((u.oid = a.group_node) AND u.rolcanlogin)))
+                  WHERE ((a.leaf_node = r.oid) AND (r.oid <> a.group_node))) mou(memberof_users) ON (true))
+             JOIN LATERAL ( SELECT array_agg(DISTINCT a.group_node ORDER BY a.group_node) AS array_agg
+                   FROM (pg_role_relationship a
+                     JOIN pg_roles u ON (((u.oid = a.group_node) AND (NOT u.rolcanlogin))))
+                  WHERE ((a.leaf_node = r.oid) AND (r.oid <> a.group_node))) mog(memberof_groups) ON (true))
+             JOIN LATERAL ( SELECT array_agg(DISTINCT a.leaf_node ORDER BY a.leaf_node) AS array_agg
+                   FROM (pg_role_relationship a
+                     JOIN pg_roles u ON (((u.oid = a.leaf_node) AND u.rolcanlogin)))
+                  WHERE ((a.group_node = r.oid) AND (r.oid <> a.leaf_node))) mu(member_users) ON (true))
+             JOIN LATERAL ( SELECT array_agg(DISTINCT a.leaf_node ORDER BY a.leaf_node) AS array_agg
+                   FROM (pg_role_relationship a
+                     JOIN pg_roles u ON (((u.oid = a.leaf_node) AND (NOT u.rolcanlogin))))
+                  WHERE ((a.group_node = r.oid) AND (r.oid <> a.leaf_node))) mg(member_groups) ON (true))
+        ), cte_role_graph AS (
+         SELECT leaf_role.oid,
+            leaf_role.role_type,
+            leaf_role.rolname,
+            leaf_role.rolsuper,
+            array_to_string(ARRAY( SELECT vals.v
+                   FROM ( VALUES ('Superuser'::text)) vals(v)
+                  WHERE leaf_role.rolsuper
+                UNION ALL
+                 SELECT vals.v
+                   FROM ( VALUES ('Create Role'::text)) vals(v)
+                  WHERE leaf_role.rolcreaterole
+                UNION ALL
+                 SELECT (
+                        CASE
+                            WHEN bool_or(grant_instance.got_auth) THEN 'of '::text
+                            ELSE ''::text
+                        END ||
+                        CASE
+                            WHEN (cardinality(grant_instance.via) > 1) THEN format('%I via %s'::text, other_role.rolname, string_agg((((quote_ident((ancestor_role.rolname)::text) || '/'::text) || quote_ident((grant_role.rolname)::text)) ||
+                            CASE
+                                WHEN (grant_instance.level > 2) THEN (('[+'::text || (grant_instance.level - 2)) || ']'::text)
+                                ELSE ''::text
+                            END), ('
+'::text || repeat(' '::text, (length((other_role.rolname)::text) + 5)))))
+                            ELSE format('%I from %s'::text, other_role.rolname, string_agg(quote_ident((grant_role.rolname)::text), ', '::text))
+                        END)
+                   FROM ((((unnest(leaf_role.memberof_users) other(other)
+                     JOIN pg_roles other_role ON ((other_role.oid = other.other)))
+                     JOIN pg_role_relationship grant_instance ON (((grant_instance.leaf_node = leaf_role.oid) AND (grant_instance.group_node = other.other))))
+                     JOIN pg_roles grant_role ON ((grant_role.oid = grant_instance.grantor)))
+                     LEFT JOIN pg_roles ancestor_role ON ((ancestor_role.oid = grant_instance.via[(cardinality(grant_instance.via) - 1)])))
+                  GROUP BY other_role.rolname, grant_instance.via
+                 HAVING bool_or(grant_instance.got_auth)
+                UNION ALL
+                 SELECT (
+                        CASE
+                            WHEN bool_or(grant_instance.got_auth) THEN 'of '::text
+                            ELSE ''::text
+                        END ||
+                        CASE
+                            WHEN (cardinality(grant_instance.via) > 1) THEN format('%I via %s'::text, other_role.rolname, string_agg((((quote_ident((ancestor_role.rolname)::text) || '/'::text) || quote_ident((grant_role.rolname)::text)) ||
+                            CASE
+                                WHEN (grant_instance.level > 2) THEN (('[+'::text || (grant_instance.level - 2)) || ']'::text)
+                                ELSE ''::text
+                            END), ('
+'::text || repeat(' '::text, (length((other_role.rolname)::text) + 5)))))
+                            ELSE format('%I from %s'::text, other_role.rolname, string_agg(quote_ident((grant_role.rolname)::text), ', '::text))
+                        END)
+                   FROM ((((unnest(leaf_role.memberof_groups) other(other)
+                     JOIN pg_roles other_role ON ((other_role.oid = other.other)))
+                     JOIN pg_role_relationship grant_instance ON (((grant_instance.leaf_node = leaf_role.oid) AND (grant_instance.group_node = other.other))))
+                     JOIN pg_roles grant_role ON ((grant_role.oid = grant_instance.grantor)))
+                     LEFT JOIN pg_roles ancestor_role ON ((ancestor_role.oid = grant_instance.via[(cardinality(grant_instance.via) - 1)])))
+                  GROUP BY other_role.rolname, grant_instance.via
+                 HAVING bool_or(grant_instance.got_auth)
+                UNION ALL
+                 SELECT (
+                        CASE
+                            WHEN bool_or(grant_instance.got_auth) THEN 'by '::text
+                            ELSE ''::text
+                        END ||
+                        CASE
+                            WHEN (cardinality(grant_instance.via) > 1) THEN format('%I via %s'::text, other_role.rolname, string_agg((((quote_ident((ancestor_role.rolname)::text) || '/'::text) || quote_ident((grant_role.rolname)::text)) ||
+                            CASE
+                                WHEN (grant_instance.level > 2) THEN (('[+'::text || (grant_instance.level - 2)) || ']'::text)
+                                ELSE ''::text
+                            END), ('
+'::text || repeat(' '::text, (length((other_role.rolname)::text) + 5)))))
+                            ELSE format('%I from %s'::text, other_role.rolname, string_agg(quote_ident((grant_role.rolname)::text), ', '::text))
+                        END)
+                   FROM ((((unnest(leaf_role.member_users) other(other)
+                     JOIN pg_roles other_role ON ((other_role.oid = other.other)))
+                     JOIN pg_role_relationship grant_instance ON (((grant_instance.group_node = leaf_role.oid) AND (grant_instance.leaf_node = other.other))))
+                     JOIN pg_roles grant_role ON ((grant_role.oid = grant_instance.grantor)))
+                     LEFT JOIN pg_roles ancestor_role ON ((ancestor_role.oid = grant_instance.via[(cardinality(grant_instance.via) - 1)])))
+                  GROUP BY other_role.rolname, grant_instance.via
+                 HAVING bool_or(grant_instance.got_auth)
+                UNION ALL
+                 SELECT (
+                        CASE
+                            WHEN bool_or(grant_instance.got_auth) THEN 'by '::text
+                            ELSE ''::text
+                        END ||
+                        CASE
+                            WHEN (cardinality(grant_instance.via) > 1) THEN format('%I via %s'::text, other_role.rolname, string_agg((((quote_ident((ancestor_role.rolname)::text) || '/'::text) || quote_ident((grant_role.rolname)::text)) ||
+                            CASE
+                                WHEN (grant_instance.level > 2) THEN (('[+'::text || (grant_instance.level - 2)) || ']'::text)
+                                ELSE ''::text
+                            END), ('
+'::text || repeat(' '::text, (length((other_role.rolname)::text) + 5)))))
+                            ELSE format('%I from %s'::text, other_role.rolname, string_agg(quote_ident((grant_role.rolname)::text), ', '::text))
+                        END)
+                   FROM ((((unnest(leaf_role.member_groups) other(other)
+                     JOIN pg_roles other_role ON ((other_role.oid = other.other)))
+                     JOIN pg_role_relationship grant_instance ON (((grant_instance.group_node = leaf_role.oid) AND (grant_instance.leaf_node = other.other))))
+                     JOIN pg_roles grant_role ON ((grant_role.oid = grant_instance.grantor)))
+                     LEFT JOIN pg_roles ancestor_role ON ((ancestor_role.oid = grant_instance.via[(cardinality(grant_instance.via) - 1)])))
+                  GROUP BY other_role.rolname, grant_instance.via
+                 HAVING bool_or(grant_instance.got_auth)), '
+'::text) AS administration,
+            array_to_string(ARRAY( SELECT
+                        CASE
+                            WHEN (cardinality(grant_instance.via) > 1) THEN format('%I via %s'::text, other_role.rolname, string_agg((((quote_ident((ancestor_role.rolname)::text) || '/'::text) || quote_ident((grant_role.rolname)::text)) ||
+                            CASE
+                                WHEN (grant_instance.level > 2) THEN (('[+'::text || (grant_instance.level - 2)) || ']'::text)
+                                ELSE ''::text
+                            END), ('
+'::text || repeat(' '::text, (length((other_role.rolname)::text) + 5)))))
+                            ELSE format('%I from %s'::text, other_role.rolname, string_agg(quote_ident((grant_role.rolname)::text), ', '::text))
+                        END AS format
+                   FROM ((((unnest(leaf_role.memberof_users) other(other)
+                     JOIN pg_roles other_role ON ((other_role.oid = other.other)))
+                     JOIN pg_role_relationship grant_instance ON (((grant_instance.leaf_node = leaf_role.oid) AND (grant_instance.group_node = other.other))))
+                     JOIN pg_roles grant_role ON ((grant_role.oid = grant_instance.grantor)))
+                     LEFT JOIN pg_roles ancestor_role ON ((ancestor_role.oid = grant_instance.via[(cardinality(grant_instance.via) - 1)])))
+                  GROUP BY other_role.rolname, grant_instance.via), '
+'::text) AS memberof_users,
+            array_to_string(ARRAY( SELECT
+                        CASE
+                            WHEN (cardinality(grant_instance.via) > 1) THEN format('%I via %s'::text, other_role.rolname, string_agg((((quote_ident((ancestor_role.rolname)::text) || '/'::text) || quote_ident((grant_role.rolname)::text)) ||
+                            CASE
+                                WHEN (grant_instance.level > 2) THEN (('[+'::text || (grant_instance.level - 2)) || ']'::text)
+                                ELSE ''::text
+                            END), ('
+'::text || repeat(' '::text, (length((other_role.rolname)::text) + 5)))))
+                            ELSE format('%I from %s'::text, other_role.rolname, string_agg(quote_ident((grant_role.rolname)::text), ', '::text))
+                        END AS format
+                   FROM ((((unnest(leaf_role.memberof_groups) other(other)
+                     JOIN pg_roles other_role ON ((other_role.oid = other.other)))
+                     JOIN pg_role_relationship grant_instance ON (((grant_instance.leaf_node = leaf_role.oid) AND (grant_instance.group_node = other.other))))
+                     JOIN pg_roles grant_role ON ((grant_role.oid = grant_instance.grantor)))
+                     LEFT JOIN pg_roles ancestor_role ON ((ancestor_role.oid = grant_instance.via[(cardinality(grant_instance.via) - 1)])))
+                  GROUP BY other_role.rolname, grant_instance.via), '
+'::text) AS memberof_groups,
+            array_to_string(ARRAY( SELECT
+                        CASE
+                            WHEN (cardinality(grant_instance.via) > 1) THEN format('%I via %s'::text, other_role.rolname, string_agg((((quote_ident((ancestor_role.rolname)::text) || '/'::text) || quote_ident((grant_role.rolname)::text)) ||
+                            CASE
+                                WHEN (grant_instance.level > 2) THEN (('[+'::text || (grant_instance.level - 2)) || ']'::text)
+                                ELSE ''::text
+                            END), ('
+'::text || repeat(' '::text, (length((other_role.rolname)::text) + 5)))))
+                            ELSE format('%I from %s'::text, other_role.rolname, string_agg(quote_ident((grant_role.rolname)::text), ', '::text))
+                        END AS format
+                   FROM ((((unnest(leaf_role.member_users) other(other)
+                     JOIN pg_roles other_role ON ((other_role.oid = other.other)))
+                     JOIN pg_role_relationship grant_instance ON (((grant_instance.group_node = leaf_role.oid) AND (grant_instance.leaf_node = other.other))))
+                     JOIN pg_roles grant_role ON ((grant_role.oid = grant_instance.grantor)))
+                     LEFT JOIN pg_roles ancestor_role ON ((ancestor_role.oid = grant_instance.via[(cardinality(grant_instance.via) - 1)])))
+                  GROUP BY other_role.rolname, grant_instance.via), '
+'::text) AS member_users,
+            array_to_string(ARRAY( SELECT
+                        CASE
+                            WHEN (cardinality(grant_instance.via) > 1) THEN format('%I via %s'::text, other_role.rolname, string_agg((((quote_ident((ancestor_role.rolname)::text) || '/'::text) || quote_ident((grant_role.rolname)::text)) ||
+                            CASE
+                                WHEN (grant_instance.level > 2) THEN (('[+'::text || (grant_instance.level - 2)) || ']'::text)
+                                ELSE ''::text
+                            END), ('
+'::text || repeat(' '::text, (length((other_role.rolname)::text) + 5)))))
+                            ELSE format('%I from %s'::text, other_role.rolname, string_agg(quote_ident((grant_role.rolname)::text), ', '::text))
+                        END AS format
+                   FROM ((((unnest(leaf_role.member_groups) other(other)
+                     JOIN pg_roles other_role ON ((other_role.oid = other.other)))
+                     JOIN pg_role_relationship grant_instance ON (((grant_instance.group_node = leaf_role.oid) AND (grant_instance.leaf_node = other.other))))
+                     JOIN pg_roles grant_role ON ((grant_role.oid = grant_instance.grantor)))
+                     LEFT JOIN pg_roles ancestor_role ON ((ancestor_role.oid = grant_instance.via[(cardinality(grant_instance.via) - 1)])))
+                  GROUP BY other_role.rolname, grant_instance.via), '
+'::text) AS member_groups
+           FROM role_graph_detail leaf_role
+        )
+ SELECT cte_role_graph.rolname,
+    cte_role_graph.administration,
+    cte_role_graph.memberof_users,
+    cte_role_graph.memberof_groups,
+    cte_role_graph.member_users,
+    cte_role_graph.member_groups,
+    cte_role_graph.role_type,
+    cte_role_graph.oid,
+    row_number() OVER (ORDER BY cte_role_graph.role_type,
+        CASE
+            WHEN cte_role_graph.rolsuper THEN (cte_role_graph.oid)::integer
+            ELSE NULL::integer
+        END,
+        CASE
+            WHEN (cte_role_graph.rolname ~ 'pg_'::text) THEN 0
+            ELSE 1
+        END, cte_role_graph.rolname) AS seq
+   FROM cte_role_graph
+  ORDER BY (row_number() OVER (ORDER BY cte_role_graph.role_type,
+        CASE
+            WHEN cte_role_graph.rolsuper THEN (cte_role_graph.oid)::integer
+            ELSE NULL::integer
+        END,
+        CASE
+            WHEN (cte_role_graph.rolname ~ 'pg_'::text) THEN 0
+            ELSE 1
+        END, cte_role_graph.rolname));
diff --git a/src/bin/psql/command.c b/src/bin/psql/command.c
index a141146e70..77730643c4 100644
--- a/src/bin/psql/command.c
+++ b/src/bin/psql/command.c
@@ -881,6 +881,18 @@ exec_command_d(PsqlScanState scan_state, bool active_branch, const char *cmd)
 
 					free(pattern2);
 				}
+				else if (cmd[2] == 'r')
+				{
+					success = describeRoleGraph(pattern, show_verbose, show_system);
+				}
+				else if (cmd[2] == 'u')
+				{
+					success = describeUsers(pattern, show_verbose, show_system);
+				}
+				else if (cmd[2] == 'g')
+				{
+					success = describeGroups(pattern, show_verbose, show_system);
+				}
 				else
 					status = PSQL_CMD_UNKNOWN;
 				break;
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index c645d66418..bb56e54a01 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -36,6 +36,7 @@ static bool describeOneTableDetails(const char *schemaname,
 									bool verbose);
 static void add_tablespace_footer(printTableContent *const cont, char relkind,
 								  Oid tablespace, const bool newline);
+static bool query_and_print_role_graph(const char *pattern, bool verbose, bool showSystem, char *header, char *roleType);
 static void add_role_attribute(PQExpBuffer buf, const char *const str);
 static bool listTSParsersVerbose(const char *pattern);
 static bool describeOneTSParser(const char *oid, const char *nspname,
@@ -3744,6 +3745,113 @@ describeRoles(const char *pattern, bool verbose, bool showSystem)
 	return true;
 }
 
+/*
+ * \drr
+ * Describes the role graph for all roles
+ */
+bool
+describeRoleGraph(const char *pattern, bool verbose, bool showSystem)
+{
+	return query_and_print_role_graph(pattern, verbose, showSystem, _("List of role graphs"), NULL);
+}
+
+/*
+ * \dru
+ * Describes roles that behave as users, specifically, they can login.
+ * The design of this output assumes they are also a member of one ormore roles.
+ */
+bool
+describeUsers(const char *pattern, bool verbose, bool showSystem)
+{
+	return query_and_print_role_graph(pattern, verbose, showSystem, _("List of user graphs"), "User");
+}
+
+/*
+ * \drg
+ * Describes roles that behave as groups, specifically they have one or
+ * more member roles.
+ */
+bool
+describeGroups(const char *pattern, bool verbose, bool showSystem)
+{
+	return query_and_print_role_graph(pattern, verbose, showSystem, _("List of group graphs"), "Group");
+}
+
+static bool
+query_and_print_role_graph(const char *pattern, bool verbose, bool showSystem, char *header, char *roleType)
+{
+	PQExpBufferData buf;
+	PGresult   *res;
+	printTableContent cont;
+	printTableOpt myopt = pset.popt.topt;
+	int			ncols = 6;
+	int			nrows = 0;
+	int			i;
+	const char	align = 'l';
+	myopt.default_footer = false;
+
+	initPQExpBuffer(&buf);
+
+	appendPQExpBufferStr(&buf,
+					  "SELECT rg.rolname,\n"
+					  "  rg.administration,\n"
+					  "  rg.memberof_groups, rg.member_groups, rg.member_users, rg.memberof_users\n"
+					  "FROM pg_catalog.pg_roles r\n"
+					  "JOIN pg_catalog.pg_role_graph rg ON rg.oid = r.oid\n"
+					  "WHERE ");
+
+	if (roleType)
+		appendPQExpBuffer(&buf, "rg.role_type = '%s'\n", roleType);
+	else
+		appendPQExpBufferStr(&buf, "true\n");
+
+	if (!showSystem && !pattern)
+		appendPQExpBufferStr(&buf, "AND r.rolname !~ '^pg_'\n");
+
+	if (!validateSQLNamePattern(&buf, pattern, true, false,
+								NULL, "rg.rolname", NULL, NULL,
+								NULL, 1))
+	{
+		termPQExpBuffer(&buf);
+		return false;
+	}
+
+	appendPQExpBufferStr(&buf,
+					  "ORDER BY rg.seq\n"
+					  ";");
+
+	res = PSQLexec(buf.data);
+	if (!res)
+		return false;
+
+	nrows = PQntuples(res);
+
+	termPQExpBuffer(&buf);
+
+	printTableInit(&cont, &myopt, header, ncols, nrows);
+
+	printTableAddHeader(&cont, gettext_noop("Role name"), true, align);
+	printTableAddHeader(&cont, gettext_noop("Administration"), true, align);
+	printTableAddHeader(&cont, gettext_noop("Member of Groups"), true, align);
+	printTableAddHeader(&cont, gettext_noop("Group Members"), true, align);
+	printTableAddHeader(&cont, gettext_noop("User Members"), true, align);
+	printTableAddHeader(&cont, gettext_noop("Member of Users"), true, align);
+
+	for (i = 0; i < nrows; i++)
+	{
+		printTableAddCell(&cont, PQgetvalue(res, i, 0), false, false);
+		printTableAddCell(&cont, PQgetvalue(res, i, 1), false, false);
+		printTableAddCell(&cont, PQgetvalue(res, i, 2), false, false);
+		printTableAddCell(&cont, PQgetvalue(res, i, 3), false, false);
+		printTableAddCell(&cont, PQgetvalue(res, i, 4), false, false);
+		printTableAddCell(&cont, PQgetvalue(res, i, 5), false, false);
+	}
+
+	printTable(&cont, pset.queryFout, false, pset.logfile);
+	printTableCleanup(&cont);
+	return true;
+}
+
 static void
 add_role_attribute(PQExpBuffer buf, const char *const str)
 {
diff --git a/src/bin/psql/describe.h b/src/bin/psql/describe.h
index 7872c71f58..3c06069072 100644
--- a/src/bin/psql/describe.h
+++ b/src/bin/psql/describe.h
@@ -34,6 +34,15 @@ extern bool describeOperators(const char *oper_pattern,
 /* \du, \dg */
 extern bool describeRoles(const char *pattern, bool verbose, bool showSystem);
 
+/* \drr */
+extern bool describeRoleGraph(const char *pattern, bool verbose, bool showSystem);
+
+/* \dru */
+extern bool describeUsers(const char *pattern, bool verbose, bool showSystem);
+
+/* \drg */
+extern bool describeGroups(const char *pattern, bool verbose, bool showSystem);
+
 /* \drds */
 extern bool listDbRoleSettings(const char *pattern, const char *pattern2);
 
-- 
2.25.1

