PostgreSQL pg_auth_members vs MySQL role_edges extraction
This page shows how to read role-to-role membership from PostgreSQL and MySQL, which store it in completely different catalog shapes, and fold both into a single canonical edge set that a drift engine can diff without engine-specific special-casing.
Role membership is the backbone of any RBAC model: dba_group grants read_write grants app_svc, and effective privilege flows along those edges. But PostgreSQL keeps membership in pg_auth_members as OID pairs, while MySQL 8 keeps it in mysql.role_edges as user@host pairs — and, critically, a MySQL edge grants nothing in a session until the role is activated. If your extractor treats a MySQL role edge the way it treats a PostgreSQL one, you will report privileges that no session actually holds. The adapter here is one of the cross-DB parser adapters that make heterogeneous fleets diffable.
When to use this — and when not to
Use this two-engine extraction and normalization when:
- You run both PostgreSQL and MySQL 8 and need a single drift baseline that compares role hierarchies across them without maintaining two divergent diff paths.
- Your model depends on effective membership — you care whether a grant is actually active in a session, not merely recorded in a catalog.
- You already ingest object grants and want membership edges in the same canonical shape so inheritance can be resolved consistently.
Do not reach for this when:
- You run a single engine. A direct query against that engine’s catalog is simpler than a normalization layer you do not need.
- You only care about direct object grants and never resolve transitive inheritance — then role edges are noise for your use case.
- You are on MySQL 5.7 or MariaDB, which have no
role_edgescatalog at all; the activation model and storage differ and this adapter does not apply unmodified.
Step-by-step implementation
Step 1 — Extract PostgreSQL membership from pg_auth_members
pg_auth_members stores only OIDs, so each row must be joined to pg_roles twice — once to name the group role (roleid) and once to name the member (member). Membership in PostgreSQL takes effect according to the member role’s INHERIT attribute and is available on login, so every extracted edge is effectively active. See the PostgreSQL role membership docs for the inheritance rules.
SELECT grp.rolname AS role_name,
mbr.rolname AS grantee,
am.admin_option,
mbr.rolinherit AS member_inherits
FROM pg_auth_members am
JOIN pg_roles grp ON grp.oid = am.roleid
JOIN pg_roles mbr ON mbr.oid = am.member
WHERE grp.rolname NOT LIKE 'pg\_%'
ORDER BY grantee, role_name;
Verification — a member should appear once per role it holds:
role_name | grantee | admin_option | member_inherits
------------+-------------+--------------+-----------------
read_write | app_svc | f | t
read_only | reporting | f | t
Step 2 — Extract MySQL membership and resolve the activation gap
mysql.role_edges records the grant, but a granted role contributes zero privilege in a session unless it is one of the account’s default roles (or is activated with SET ROLE, or the server has activate_all_roles_on_login enabled). Left-join mysql.default_roles to compute an auto_activated flag per edge. The MySQL roles docs describe activation precisely.
SELECT re.FROM_USER AS role_name,
re.TO_USER AS grantee,
re.WITH_ADMIN_OPTION AS admin_option,
(dr.DEFAULT_ROLE_USER IS NOT NULL) AS auto_activated
FROM mysql.role_edges re
LEFT JOIN mysql.default_roles dr
ON dr.USER = re.TO_USER AND dr.HOST = re.TO_HOST
AND dr.DEFAULT_ROLE_USER = re.FROM_USER
AND dr.DEFAULT_ROLE_HOST = re.FROM_HOST
ORDER BY grantee, role_name;
Verification — an inert edge is visible but flagged inactive:
role_name | grantee | admin_option | auto_activated
------------+---------+--------------+---------------
app_write | app_svc | N | 1
audit_read | app_svc | N | 0
Step 3 — Normalize both into one canonical edge set
Fold both result sets into a single frozen tuple so the diff engine never branches on engine. The active flag is the load-bearing normalization: True for every PostgreSQL edge, and the auto_activated value for MySQL. This mirrors the normalization contract in understanding RBAC inheritance in PostgreSQL vs MySQL.
from dataclasses import dataclass
@dataclass(frozen=True)
class RoleEdge:
grantee: str # role that receives membership
role: str # role being granted
admin: bool # WITH ADMIN / admin_option
active: bool # effective in a session right now
def from_postgres(row) -> RoleEdge:
return RoleEdge(row["grantee"], row["role_name"],
bool(row["admin_option"]), active=True)
def from_mysql(row) -> RoleEdge:
return RoleEdge(row["grantee"], row["role_name"],
row["admin_option"] == "Y", active=bool(row["auto_activated"]))
Verification — both engines yield comparable tuples, and the inert MySQL edge is preserved but marked inactive:
pg = from_postgres({"grantee": "app_svc", "role_name": "read_write",
"admin_option": False})
my = from_mysql({"grantee": "app_svc", "role_name": "audit_read",
"admin_option": "N", "auto_activated": 0})
assert pg == RoleEdge("app_svc", "read_write", False, True)
assert my.active is False # recorded, not effective
Worked example: PostgreSQL 15 and MySQL 8.0 side by side
Scenario: the account app_svc exists on both engines. On PostgreSQL 15 it is a member of read_write. On MySQL 8.0 it has been granted both app_write (set as a default role) and audit_read (granted but never made default). A naive extractor would report three effective memberships; the correct effective set is two.
canonical = {from_postgres({"grantee": "app_svc", "role_name": "read_write",
"admin_option": False})}
canonical |= {
from_mysql({"grantee": "app_svc", "role_name": "app_write",
"admin_option": "N", "auto_activated": 1}),
from_mysql({"grantee": "app_svc", "role_name": "audit_read",
"admin_option": "N", "auto_activated": 0}),
}
effective = {e for e in canonical if e.active}
print(sorted((e.role for e in effective)))
print(sorted((e.role for e in canonical if not e.active)))
Expected output:
['app_write', 'read_write']
['audit_read']
The audit_read edge is retained in the canonical set — you want it in your baseline so that a later change activating it registers as drift — but it is excluded from the effective privilege graph. Diffing on the effective set stops the pipeline from alerting on a grant that grants nothing.
Gotchas and engine-specific notes
OIDs are cluster-local. A pg_auth_members OID is meaningless outside its database cluster and can be reused after DROP ROLE. Always resolve to rolname at extraction time — never cache raw OIDs across snapshots or environments.
MySQL user@host is a two-part identity. role_edges keys on both TO_USER and TO_HOST. app_svc@'%' and app_svc@'10.0.%' are different grantees, and default-role activation is matched per host. Collapsing to the user part alone will mismatch activation and produce phantom active/inactive flips.
activate_all_roles_on_login overrides default_roles. If the server variable is ON, every granted role activates on login regardless of mysql.default_roles. Read the variable and, when set, treat all MySQL edges as active — otherwise you will mark genuinely effective roles as inert.
PostgreSQL 16 split the admin/set/inherit options. From PostgreSQL 16, pg_auth_members carries separate inherit_option and set_option columns, and admin_option no longer implies inheritance. On 16+, read inherit_option to decide whether membership actually confers privilege, rather than assuming it from membership alone.
Reserved roles are not drift. Filter pg_% bootstrap roles in PostgreSQL and the internal mysql.session/mysql.sys accounts, or every baseline reports built-in noise that is identical on every host anyway.
Compliance note
A normalized, engine-neutral membership graph is the evidence backbone for least-privilege review under SOC 2 CC6.3 (role-based access is provisioned and reviewed). Because the canonical edge set records both recorded and effective membership, an auditor can see not just who was granted a role but whether that grant confers privilege today — the exact distinction MySQL’s activation model otherwise obscures. Exported as a per-host edge list with the active flag, it produces a defensible artifact showing that access flowing through role inheritance was enumerated identically across a heterogeneous fleet, which feeds directly into the drift detection engines baseline.
Frequently asked questions
Why join pg_roles twice instead of once?
Because pg_auth_members stores two OIDs per row — the group role in roleid and the member in member — and each must be resolved to a name independently. A single join names only one side and leaves the other as an integer.
Does a role in mysql.role_edges always grant its privileges?
No. That is the activation gap. A role edge confers privilege only when the role is a default role for the account, is activated via SET ROLE, or the server has activate_all_roles_on_login enabled. Otherwise the edge is recorded but inert in a session.
Should inactive MySQL edges be dropped from the baseline?
Keep them in the canonical set with active=False, but exclude them from the effective privilege graph. Retaining them means a later change that activates the role registers as drift instead of appearing from nowhere.
How do I compare admin/grant options across engines?
Normalize both to a boolean admin field: PostgreSQL’s admin_option and MySQL’s WITH_ADMIN_OPTION (‘Y’/‘N’) map cleanly. On PostgreSQL 16+, read inherit_option separately since admin no longer implies inheritance.
Related
- Understanding RBAC Inheritance in PostgreSQL vs MySQL — how inheritance resolves once edges are normalized
- Drift Detection Engines & Diff Logic — where the canonical edge set is diffed
- Cross-DB Parser Adapters — the adapter family this extraction belongs to