How to map database roles to least-privilege access
This page shows how to convert an existing set of over-broad database grants into an auditable, least-privilege role map, then keep that map from drifting — using catalog queries, a version-controlled desired-state manifest, and a transactional dry-run before any GRANT or REVOKE reaches production.
Least-privilege mapping is the point where an abstract compliance requirement (“analysts read, they do not write”) becomes a concrete, machine-verifiable set of grants. It sits directly on top of your role hierarchy design: a clean hierarchy makes the mapping tractable, and a well-defined mapping is what a drift pipeline compares the live catalog against on every run.
When to apply this mapping — and when not to
Use the workflow on this page when:
- You already have a defined role hierarchy (base roles inheriting from scoped capability roles) and need to bind concrete object privileges to each role deterministically.
- Grants are provisioned or changed by more than one actor — CI pipelines, on-call DBAs, migration tools — so the live state routinely diverges from what anyone intended.
- An auditor or a control owner needs every privilege assignment to trace back to a specific requirement, and you need a repeatable artifact to prove it.
Do not reach for this workflow when:
- The database has a single application role and no service accounts — a hand-written
GRANTscript under version control is enough, and a diff pipeline is overhead. - You have not yet fixed circular or overlapping role membership. Resolve those in the hierarchy first; mapping privileges onto a broken graph only encodes the breakage.
Step 1: Inventory the live grant graph
Least-privilege work starts from ground truth, not from the manifest you think is deployed. Query the catalog directly so the current state is materialized as data you can diff. In PostgreSQL, information_schema.role_table_grants exposes every object-level privilege as one row:
SELECT
grantee AS role_name,
table_schema,
table_name,
privilege_type,
is_grantable
FROM information_schema.role_table_grants
WHERE grantee NOT IN ('postgres', 'PUBLIC')
ORDER BY grantee, table_schema, table_name, privilege_type;
Pair it with the membership edges from pg_auth_members joined to pg_roles, so you capture inheritance as well as direct object grants:
SELECT
m.rolname AS member,
r.rolname AS granted_role,
a.admin_option
FROM pg_auth_members a
JOIN pg_roles m ON m.oid = a.member
JOIN pg_roles r ON r.oid = a.roleid
ORDER BY member, granted_role;
Verification: the first query should return one row per (role, object, privilege) triple. Spot-check a known service account — for example svc_etl_loader should appear only with INSERT/UPDATE on the ETL schema and nowhere near schema_admin objects. Any row that surprises you is drift you are about to formalize away.
Step 2: Author the desired-state manifest
Express intent as a version-controlled manifest — YAML or JSON — where each role lists exactly the privileges it is allowed to hold. This is the artifact that binds a role to specific objects, actions, and scope, and it is the practical expression of privilege scope mapping:
# roles.desired.yaml — checked into version control, reviewed via PR
roles:
app_read:
grants:
- {schema: analytics, table: events, privileges: [SELECT]}
- {schema: analytics, table: sessions, privileges: [SELECT]}
svc_etl_loader:
inherits: [app_read]
grants:
- {schema: staging, table: events_raw, privileges: [INSERT, UPDATE]}
Load and normalize it into the same shape the catalog query produces — a flat set of (role, schema, table, privilege) tuples — so the two states are directly comparable:
import yaml
def load_desired(path: str) -> set[tuple[str, str, str, str]]:
"""Flatten the desired-state manifest into comparable grant tuples."""
doc = yaml.safe_load(open(path, encoding="utf-8"))
desired: set[tuple[str, str, str, str]] = set()
for role, spec in doc["roles"].items():
for g in spec.get("grants", []):
for priv in g["privileges"]:
desired.add((role, g["schema"], g["table"], priv))
return desired
Verification: len(load_desired("roles.desired.yaml")) should equal the number of privilege lines you authored (4 for the sample above). A mismatch means a duplicated or malformed grant entry.
Step 3: Compute the privilege delta
With both states as tuple sets, the delta is plain set arithmetic. Privileges present live but absent from the manifest are REVOKE operations; privileges in the manifest but missing live are GRANT operations. Emit revokes first — collapsing a broad privilege before adding the narrow one prevents a momentary window of excess access, which is the ordering that grant and revoke chain logic depends on:
def plan(current: set[tuple[str, str, str, str]],
desired: set[tuple[str, str, str, str]]) -> list[str]:
"""Return idempotent DDL: revokes before grants, deterministic order."""
to_revoke = sorted(current - desired)
to_grant = sorted(desired - current)
stmts: list[str] = []
for role, schema, table, priv in to_revoke:
stmts.append(f'REVOKE {priv} ON {schema}.{table} FROM "{role}";')
for role, schema, table, priv in to_grant:
stmts.append(f'GRANT {priv} ON {schema}.{table} TO "{role}";')
return stmts
Verification: running plan(current, desired) on a database that already matches the manifest returns []. That empty result is the idempotency contract — a converged system produces no operations, so the same pipeline can run on every commit without churning grants.
Step 4: Dry-run inside a transaction before committing
Never let generated DDL touch production unvalidated. Execute the plan inside an explicit transaction and roll back unconditionally: the engine parses and evaluates every statement — catching typos, missing objects, and privilege-check failures — while the rollback discards all side effects.
import psycopg
def dry_run_remediation(dsn: str, statements: list[str]) -> list[str]:
"""
Execute remediation SQL inside a transaction, then roll back
unconditionally. Returns the log of statements that parsed and ran.
Validation only — never commit from this function.
"""
executed: list[str] = []
with psycopg.connect(dsn, autocommit=False) as conn:
try:
for sql in statements:
conn.execute(sql)
executed.append(sql)
except Exception as exc:
conn.rollback()
raise RuntimeError(f"Dry-run failed on: {sql!r}") from exc
finally:
conn.rollback() # always roll back — this is a dry run
return executed
Verification: a clean dry-run returns the full statement list with no exception. Only after that promote the identical plan to a committed transaction (autocommit=False → conn.commit()), so the reviewed plan and the applied plan are byte-for-byte the same.
current − desired) before grants (desired − current), and only a clean transactional dry-run promotes the byte-for-byte identical plan to a committed apply.Worked example: PostgreSQL 15, a CI account scoped to one schema
Scenario: a CI service account ci_migrator was granted ALL on the analytics schema during an incident and never walked back. Policy says it should hold only SELECT and INSERT on analytics.events.
Live state from Step 1:
ci_migrator | analytics | events | SELECT | NO
ci_migrator | analytics | events | INSERT | NO
ci_migrator | analytics | events | UPDATE | NO
ci_migrator | analytics | events | DELETE | NO
ci_migrator | analytics | sessions | SELECT | NO
Desired manifest for the account:
ci_migrator:
grants:
- {schema: analytics, table: events, privileges: [SELECT, INSERT]}
The plan from Step 3 emits revokes first, then no new grants (both desired privileges already exist):
REVOKE DELETE ON analytics.events FROM "ci_migrator";
REVOKE UPDATE ON analytics.events FROM "ci_migrator";
REVOKE SELECT ON analytics.sessions FROM "ci_migrator";
After a clean dry-run and commit, re-running Step 1 returns exactly the two allowed rows, and re-running Step 3 returns [] — the account is now converged and least-privileged, and the next scheduled run will confirm no drift.
PostgreSQL vs MySQL grant-mapping gotchas
The set-difference method is engine-agnostic, but the catalog you read and the way privileges resolve are not. These differences are covered in depth in understanding RBAC inheritance in PostgreSQL vs MySQL; the mapping-specific ones to guard against:
- Catalog source. PostgreSQL grants live in
information_schema.role_table_grantsand membership inpg_auth_members. MySQL 8.0+ keeps the static grant graph ininformation_schema.role_edges(backed bymysql.role_edges) and per-user activation inmysql.default_roles. A manifest normalized from one will not diff correctly against the other. - Session vs. static resolution. In MySQL a granted role can be dormant until
SET ROLEoractivate_all_roles_on_login, so a naiveSHOW GRANTSunder-reports and produces false-negative “missing privilege” deltas. Reconstruct effective grants fromrole_edges+default_rolesbefore diffing. WITH GRANT OPTIONcascades. In PostgreSQL,REVOKE-ing a privilege that a role re-granted to others fails unless you addCASCADE; stripping delegation is a boundary decision, not a syntax detail. Route those revokes through explicit security boundary enforcement so a cascade never silently removes access something else depends on.- Allow-union semantics. Both engines accumulate grants from every role rather than intersecting them, so there is no implicit deny. Least privilege is achieved only by revoking the broader grant, never by adding a narrower one alongside it.
Compliance mapping and the audit artifact this produces
This workflow directly satisfies the least-privilege and access-restriction controls auditors probe: SOC 2 CC6.1 and CC6.3 (logical access is restricted to what each identity requires), PCI-DSS Requirement 7 (access limited to least privilege by role), and HIPAA §164.312(a)(1) (technical access control). The evidence artifact is the pairing of the version-controlled desired-state manifest with the JSON delta report from Step 3: together they show the intended grant set, the live grant set at a point in time, and the exact GRANT/REVOKE operations that reconciled them — a reviewable, timestamped chain from control requirement to applied change. Keeping the manifest under pull-request review also gives you the change-authorization trail those same controls expect.
Frequently asked questions
Does an empty plan mean the mapping is compliant? It means the live grant graph matches the manifest for the objects the manifest covers. Compliance also depends on the manifest itself being correct — an empty delta against a permissive manifest is convergence, not least privilege. Review the manifest as carefully as the diff.
How do I handle a legitimate, temporary grant that the diff wants to revoke? Route it through an exception path rather than widening the manifest. Time-bound grants belong in a whitelist with an expiry, not in the baseline, so they clear automatically — the pattern in reducing false positives in RBAC drift alerts keeps them from firing as violations.
Should REVOKE really run before GRANT? Yes. Applying revokes first guarantees no transient window where a role holds both the broad and the narrow privilege at once. On a converged system the ordering is moot because both sets are empty, but during remediation it is the difference between a clean cutover and a momentary privilege spike.
What if a REVOKE fails because of a dependent grant?
That is a WITH GRANT OPTION cascade. Decide explicitly whether the delegation is legitimate; if not, revoke with CASCADE, and if so, correct the manifest to include the delegated grants so the diff stops flagging them.
Related
- Role Hierarchy Design — the parent topic: structuring roles into inheritance paths before you map privileges onto them.
- Understanding RBAC inheritance in PostgreSQL vs MySQL — how each engine resolves inheritance, which determines how you read the live grant graph.
- Privilege Scope Mapping — binding roles to schema-, table-, column-, and row-level scope.
- Grant and Revoke Chain Logic — dependency-safe ordering and cascade handling for the DDL this page generates.