Nobody Designs an RBAC Mess; Everyone Ends Up With One
Role hierarchies decay in four predictable stages until nobody can answer "who does this break?" Learn how it happens and what prevents it.
Join the DZone community and get the full member experience.
Join For FreeYou're about to rename a column. Five minutes of work. Someone asks the obvious question first: who does this break?
In a healthy Snowflake account, that's a query. In most accounts, it isn't. Someone runs SHOW GRANTS ON TABLE, gets a list of roles, and hits the real problem: those roles nest inside other roles, which nest inside more roles, and nobody can say with confidence where the chain ends. So the change waits. Or it ships anyway, because the maintenance window doesn't care about your archaeology project.
That gap, between wanting to know who this impacts and actually being able to find out, is your role-based access control (RBAC) debt. It usually stays invisible until the worst possible moment.
I've owned Snowflake governance at two different financial services organizations over the last several years, which means I've inherited this exact problem more than once, and cleaned it up more than once too. Here's how it happens, why it's so hard to undo, and the decisions that actually prevent it.
Nobody Designs a Mess
Nobody sits down and architects an unmanageable role hierarchy on purpose. It arrives in four stages, and every stage feels reasonable at the time.
Stage 1 — Innocent beginnings. The account is new. A handful of people need access, so someone grants a privilege directly to a user "just for this sprint." The first role gets named whatever felt right that morning, ANALYTICS_ROLE maybe, because there's only one analytics team so far.
Stage 2 — Growth by copy-paste. A second team needs access. The fastest path is cloning the first team's role and adjusting it, quirks included. There was never a naming convention, so there's nothing to copy correctly or incorrectly — every team just does what seemed sensible in isolation. Multiply this by a dozen teams, and you have a dozen roles with a dozen different mental models baked in.
Stage 3 — The workarounds. New tables don't automatically show up in existing grants, because nobody set up future grants. People patch this with one-off grants on individual objects. An incident requires emergency access; the access gets granted, the incident resolves, and the grant quietly outlives its purpose because revoking it wasn't anyone's job.
The most common version of this I've seen: non-prod needs realistic data to be useful for testing, and properly refreshing it on a schedule is expensive to build. So someone grants a non-prod role a "temporary" bridge into production raw data instead of fixing the actual refresh pipeline. The stale-data problem never gets solved, so the bridge never gets removed. Now your production PII exposure isn't just a function of who holds production roles. It's also a function of who holds non-production roles, because one of them quietly has a door into production.
Stage 4 — Entanglement. Nobody can draw the hierarchy from memory anymore. Revoking anything feels dangerous, because nobody knows what depends on it. The mess is now load-bearing — people are relying on access paths nobody remembers granting, and everyone is afraid to touch it.
Why You Can't Untangle It Later
Reconstructing the role graph itself isn't actually that hard, technically. Snowflake's role hierarchy is fully queryable, and I've built automation before that keeps a daily-refreshed map of it straight from INFORMATION_SCHEMA.APPLICABLE_ROLES. The data is there.
What you don't have is intent. Nobody recorded why a given grant exists, so every revocation becomes a gamble instead of a decision. Was this access load-bearing, or a leftover from an incident eighteen months ago? The only way to find out is to revoke it and see who complains, which is exactly the kind of change management nobody wants to sign off on.
This is also where role chaining turns from convenient into dangerous. Snowflake won't let you build a literal cycle. The platform enforces the role graph as a DAG, so ROLE_A can never chain back to itself through ROLE_B. But it won't stop you from nesting roles in ways that quietly fan a privileged grant out to people who have no idea they inherited it. Chain an access role into another access role because it's faster than doing it properly, or nest a "shared utility" role into an unrelated hierarchy to save five minutes, and you've created inheritance nobody can see happening in real time.
The difference between disciplined, one-directional nesting and an entangled graph isn't subtle once you draw it out.

There's a second attribution problem, specific to Snowflake, that compounds the first. When secondary roles are active in a session, a user can exercise privileges from every role they hold, not just their primary role. So when you're reconstructing "how did this person access this table" from query history, the primary role on the query isn't necessarily the role whose grant actually authorized the access. It could have come from any secondary role active in that session. "Who can touch this" and "which specific grant let this exact query succeed" turn out to be two different questions, and both are harder to answer than they should be.
Put together, the cost isn't slower audits. It's blast-radius analysis becoming unanswerable during an actual incident, right when you need the answer fastest.
The Decisions That Prevent It
None of this requires exotic tooling. It requires making a small number of decisions before the first grant, not after the hundredth.
Separate access roles from functional roles, and nest in one direction only. Access roles describe what can be touched. Functional roles describe who someone is. Access roles nest into functional roles. Functional roles get granted to users. Never the reverse, and never access-role-to-access-role as a shortcut. This is the single rule that prevents the silent-inheritance problem above. If chaining only ever flows one direction, tracing who has this stays a bounded, predictable operation instead of an open-ended one.
A minimal example of what that looks like as code:
# ACCESS ROLE — describes WHAT can be touched
resource "snowflake_account_role" "access_select_customer_pii" {
name = "ACCESS_SELECT_CUSTOMER_PII_MASKED"
comment = "SELECT on masked customer PII views. Owner: data-governance-team."
}
resource "snowflake_grant_privileges_to_account_role" "select_customer_pii" {
account_role_name = snowflake_account_role.access_select_customer_pii.name
privileges = ["SELECT"]
on_schema_object {
future {
object_type_plural = "VIEWS"
in_schema = snowflake_schema.customer_masked.fully_qualified_name
}
}
}
# FUNCTIONAL ROLE — describes WHO someone is.
# Composed only of access roles. Never nests another functional role.
resource "snowflake_account_role" "functional_data_analyst" {
name = "FUNCTIONAL_DATA_ANALYST"
comment = "Standard analyst role."
}
# One-directional nesting: access role -> functional role. Never the reverse.
resource "snowflake_grant_account_role" "analyst_gets_customer_access" {
role_name = snowflake_account_role.access_select_customer_pii.name
parent_role_name = snowflake_account_role.functional_data_analyst.name
}
# Functional role -> user is handled by SCIM/IdP sync in practice,
# not a static grant like this — shown here only for illustration.
resource "snowflake_grant_account_role" "assign_to_user" {
role_name = snowflake_account_role.functional_data_analyst.name
user_name = "jsmith"
}
#Resource names here are from the official snowflakedb/snowflake provider, v2.x. On the older Snowflake-Labs provider these were snowflake_role and snowflake_grant_role.
The specific Terraform syntax doesn't matter much. What matters: every grant now has an owner, a comment explaining why it exists, and a diff sitting in a pull request. The why behind a grant lives in version control instead of nowhere, which is the actual fix for the lost-intent problem from earlier.
Naming conventions before the first grant. Decide the pattern, role type, domain, environment, whatever fits your org, while there's exactly one role to name. Retrofitting a convention onto fifty existing roles is a project. Applying one from role one is free.
Never grant directly to a user. Every exception to this rule is the seed of a future audit finding. It's tempting exactly when it matters most ("just for this sprint," during an incident, for someone leaving in two weeks), which is precisely why it needs to be a rule without exceptions, not a judgment call made under pressure.
If non-prod needs production-realistic data, solve it with governed data sharing or masked replication, not a standing grant that blurs the line between environments. The bridge from Stage 3 is always framed as temporary. It never is.
Identity-driven provisioning. Tying role assignment to your identity provider means access follows someone's actual employment status. Joiners get provisioned automatically; leavers get deprovisioned automatically. Manual provisioning is exactly where "I'll revoke this later" grants come from.
Scheduled privilege audits, done as routine hygiene. Not incident response. A recurring, boring, calendar-driven review of who has what and whether it's still needed. The goal isn't to catch one dramatic violation. It's to stop small drift from compounding into Stage 4.
Untangling a Mess That Already Exists
If you're already past Stage 3, the fix looks less like a redesign and more like a controlled, incremental remediation:
- Inventory – pull the full grants graph, however ugly it looks.
- Cross-reference against actual usage – query history tells you who's really using an access path, versus who technically still has it.
- Stage revocations as tracked, reversible changes – not a big-bang cutover. Small batches, each one logged, each one able to be rolled back if something breaks.
- Monitor and iterate – the first pass won't be perfect. Treat it as a cycle, not a one-time project.
For the blast-radius question specifically — who can reach this role through any depth of nesting — the shape of the fix is a recursive walk of the grants graph, something like:
WITH RECURSIVE role_chain AS (
SELECT name AS role_name, 0 AS depth
FROM snowflake.account_usage.roles
WHERE name = 'ACCESS_SELECT_CUSTOMER_PII_MASKED'
AND deleted_on IS NULL
UNION ALL
SELECT g.grantee_name, rc.depth + 1
FROM snowflake.account_usage.grants_to_roles g
JOIN role_chain rc ON g.name = rc.role_name
WHERE g.granted_to = 'ROLE'
AND g.granted_on = 'ROLE'
AND g.deleted_on IS NULL
)
SELECT u.grantee_name AS impacted_user, MIN(rc.depth) AS min_depth
FROM role_chain rc
JOIN snowflake.account_usage.grants_to_users u
ON u.role = rc.role_name
AND u.deleted_on IS NULL
GROUP BY u.grantee_name
ORDER BY min_depth;
I've run a version of this remediation myself: roughly two dozen individually tracked changes across production and non-production, instead of one sweeping cutover. Tedious, honestly. But it works, as long as you're willing to do it in small, auditable steps instead of betting everything on one big rewrite fixing it in a single shot.
The Bill Always Comes Due
RBAC debt compounds quietly, the same way any technical debt does, except the interest gets paid in security exposure you can't quantify and audit findings you can't explain, not in slower deploys.
The cheapest day to get this right was day one. Today's the next best option. I'd rather write that sentence than be in the room when someone asks who this impacts during a live incident, and the honest answer is that nobody actually knows.
Opinions expressed by DZone contributors are their own.
Comments