DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Newsletter
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Building Enterprise File-Heavy AI Workflows: From Secure Uploads to Governed Document Intelligence
  • Are Passphrases Still Secure in the Age of AI?
  • Your Cloud Diagram Is Already Out of Date: An Operating Model for Continuous Security Architecture
  • Detection and Response Did Its Job. Now Someone Has to Actually Fix It.

Trending

  • How to Submit a Post to DZone
  • DZone's Article Submission Guidelines
  • Jakarta Batch in Practice: Reliable Chunk-Oriented Processing for Enterprise Workloads
  • From Giant Prompts to On-Demand Skills: Build an Extensible AI Agent With Progressive Disclosure
  1. DZone
  2. Software Design and Architecture
  3. Security
  4. Nobody Designs an RBAC Mess; Everyone Ends Up With One

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.

By 
Mayank Sethi user avatar
Mayank Sethi
·
Oct. 05, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
144 Views

Join the DZone community and get the full member experience.

Join For Free

You'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.

Role nesting

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:

Plain Text
 
# 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:

  1. Inventory – pull the full grants graph, however ugly it looks.
  2. Cross-reference against actual usage – query history tells you who's really using an access path, versus who technically still has it.
  3. 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.
  4. 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:

SQL
 
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.

security

Opinions expressed by DZone contributors are their own.

Related

  • Building Enterprise File-Heavy AI Workflows: From Secure Uploads to Governed Document Intelligence
  • Are Passphrases Still Secure in the Age of AI?
  • Your Cloud Diagram Is Already Out of Date: An Operating Model for Continuous Security Architecture
  • Detection and Response Did Its Job. Now Someone Has to Actually Fix It.

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook