learn-data-access-control-with-phoebe / Builder session 2 of 8
Learn Data Access Control with Phoebe · Builder session 2 of 8

A role hierarchy that survives a reorg

Session 1 produced a written design: which job needs which column, in clear or masked, in which regions. Tonight that design becomes roles. The shape you choose here decides how much work every future change costs, because a role model built as one layer needs re-granting every time the org chart moves, and the org chart always moves. Two layers, five functional roles, three access roles, and one deliberate mistake at the end so you can see what a single careless grant does to the whole picture.

🟡 Core Analytics and platform engineers 45 min
0-3 · Welcome 3-20 · Two layers, not one 20-40 · Build-along: build the five roles 40-45 · Q&A
Part 0

What tonight builds on the table

The table is still saltbox.customers: customer_id, name, email, phone, dob, national_id, region, plan, mrr, support_notes, with rows in EU, UK and APAC. The five jobs are still EU analyst, support agent, data scientist, finance analyst and APAC contractor. What changes tonight is that those five jobs stop being rows in a document and become five roles a person can actually hold, sitting on top of three roles that describe the data rather than the people.

Live = presented in the 45 minutes Self-study = the full depth, after class 🔍 Every diagram zooms

★ What you walk out with tonight. Three access roles and five functional roles built and wired, verified against the design grid from session 1 rather than against a feeling. The direction privileges travel when a role is granted to a role, demonstrated on your own warehouse. A safe procedure for deleting a role nobody will admit to owning. And the measured reason this session matters: splitting one shared role five ways moves leaks from 70 to 22 and standing privileges from 17 to 1.

LiveOne layer of roles is where role sprawl comes from3 min▶

The natural first move is one role per person-shaped thing: analyst, then analyst_eu, then analyst_eu_finance, each granted directly on tables. It works for about a quarter. Then somebody joins who is almost an EU analyst but also needs revenue, and rather than reopen the model, a sixth role appears that is a copy of the fifth plus one grant.

What follows is mechanical. Every new table means visiting every role. Every job change means auditing which of thirty near-identical roles the person should now hold. Nobody deletes anything, because nobody can prove a role is unused. Within a year the role list is longer than the user list, and the reviewer, whose entire job is reading that list, has no chance.

Real world

Saltbox had 41 roles for 23 people. Six of them differed from another role by exactly one grant, and two were identical with different spellings. The list was not the result of anybody being careless; it was the result of a single-layer model meeting eighteen months of ordinary hiring.

Self-studyThe vocabulary for tonight3 min▶
  • Access role - a role that holds privileges on objects and is granted to nothing but other roles. Named for the data it opens.
  • Functional role - a role that represents a job. It holds no direct object privileges at all; it holds access roles. Named for the person doing the job.
  • Role grant - grant role a to role b. The privileges of a become available to b, which is the upward direction from session 1.
  • Re-pointing - changing which access roles a functional role holds. The single operation a reorg should require.
  • PUBLIC - the role every user holds automatically. Anything granted here is granted to the entire future population of the account.
  • Orphan role - a role with no members and no obvious purpose. Common, and the reason a safe deletion procedure is worth having.
Part 1 · covers access roles, functional roles and the two-layer pattern

The two-layer pattern

12 min live

Two kinds of role, and the discipline is that they never mix. Access roles describe the data and are named for it. Functional roles describe jobs and are named for them. Users hold functional roles and nothing else, which is the rule that makes the whole thing reviewable.

Users hold functional roles Functional roles one per job Access roles one per data bundle Objects the data itself mara · analyst sam · support iris · scientist kofi · finance lin · contractor fr_analyst_eu fr_support_agent fr_data_scientist fr_finance fr_contractor_apac ac_cust_read ac_cust_pii ac_cust_fin region, plan, id name, email, phone mrr Users never hold an access role directly; a functional role is all a person gets. Short names shown here. The full role names are in the build-along.
🔍 Click to zoom - four columns, and a person only ever appears in the first two.
LiveAccess roles: named for the data4 min▶

One access role per meaningful bundle of privileges on objects. Meaningful means a bundle that at least two jobs might plausibly want, or one job wants and the design treats as a unit. For Saltbox the design produces three.

  • ac_customers_read - the non-sensitive projection: customer_id, region, plan. Everybody who touches the table needs this and nothing about it is contentious.
  • ac_customers_pii_read - name, email, phone and support_notes. One job needs it in clear. Every other job's relationship to this bundle is an argument that should happen once, here, rather than per table.
  • ac_customers_finance_read - mrr. Not personal data, still restricted, and separated because the set of jobs that needs revenue is not the set that needs contact details.

Two properties make these roles do their job. They are granted to nothing but other roles, never to a user, so there is no path by which a person accumulates object privileges outside the model. And they are named for what they open, so the name survives the reorg that renames the team.

Do not create one access role per column. Ten columns produce ten roles, then thirty when the second table arrives, and you have rebuilt sprawl one layer down. Bundle by the classification tiers from session 1: non-sensitive, contact PII, highly sensitive, financial. If a bundle needs splitting later, splitting one access role is cheap because only the functional roles point at it.

LiveFunctional roles: named for the job4 min▶

One functional role per job in the written design, and the design already named five. A functional role holds access roles and holds no direct privileges on any object, which is the constraint that keeps the two layers from silently becoming one.

Functional roleHoldsBecause the design says
fr_analyst_euac_customers_read, ac_customers_finance_readcustomer_id, region, plan, mrr in clear, EU rows only
fr_support_agentac_customers_read, ac_customers_pii_readcontact details and support_notes in clear, EU and UK
fr_data_scientistac_customers_read, ac_customers_finance_readregion, plan, mrr in clear, customer_id masked, all regions
fr_financeac_customers_read, ac_customers_finance_readcustomer_id, region, plan, mrr in clear, all regions
fr_contractor_apacac_customers_readregion and plan only, APAC rows only

Notice that three functional roles hold an identical pair of access roles and are still three roles. They differ on the row dimension, which session 3 handles, and on the masking of customer_id for the scientist, which session 4 handles. Collapsing them into one role now would make both of those sessions impossible to express, and it is exactly the collapse that produced the shared role the bench scores at 109 leaks.

A functional role with a direct object grant is a bug, and it is worth checking for. It usually arrives as an emergency: somebody needs one table today and creating an access role feels like ceremony. Six months later that grant is invisible, because the reviewer reads the access-role layer and this one is not in it.

LiveWhy two layers survive a reorg4 min▶

Reorgs are not an edge case, they are the weather. The two-layer model earns its keep because each of the three common changes touches exactly one thing.

  • A job changes. The EU analyst picks up revenue responsibility. You grant one access role to one functional role. No object is re-granted, no user is touched, and every other job is untouched by construction.
  • A new table arrives. You create one access role for it and grant it to the functional roles that need it. In a single-layer model this is a visit to every job-shaped role, which is where the copies come from.
  • A person moves. Revoke one functional role, grant another. There is nothing else to check, because a person has never held anything else.

The cost is one extra layer of indirection and roughly three more roles. The saving is that the number of grant operations per change stops scaling with the size of the estate, which is the property that actually decides whether a model is still true in two years.

The reorg test the EU analyst picks up revenue on Monday Before mara fr_analyst_eu ac_cust_read objects, grants on them, every other role After mara fr_analyst_eu ac_cust_read ac_cust_fin unchanged, all of it, by construction One grant added. No user changed and no object was re-granted. A new table adds one access role instead of a grant on every job. A job that disappears is one functional role to drop, and its members are listed.
🔍 Click to zoom - one edge moves, the rest of the graph holds still.
LiveThe naming convention is load-bearing4 min▶

Naming reads as cosmetic and is not, because a role list is the artifact a reviewer actually reads. They are not going to open every role and enumerate its grants; they are going to scan a list of names and stop on the ones that look wrong. That only works if a name carries its layer, its subject and its scope.

NameWhat a reviewer learns from it
ac_customers_pii_readAccess layer, customers table, contact class, read only. Four facts, no clicks
fr_contractor_apacFunctional layer, a contractor job, APAC scope. Immediately checkable against the design
analyst2_new_FINALNothing. It cannot be reviewed, cannot be safely deleted, and will outlive everybody who knows what it was
temp_migrationThat it was temporary at some point. No layer, no subject, no scope, no end date

Three rules are enough. Prefix by layer, so ac_ and fr_ sort into two blocks and a functional role holding a direct grant becomes visible. Name access roles after the object and the class, never after the team that first asked. Name functional roles after the job, never after the person, because fr_marias_role is a role nobody will ever revoke.

Write the convention down next to the access design and enforce it in review. Naming conventions do not survive on taste; they survive because a pull request that breaks one gets a comment. The estate-wide version of this is cheaper than the alternative, which is a rename programme in year two touching every grant statement you have.

Part 2 · covers role grants, deletion, PUBLIC and the measured payoff

The operations that go wrong

6 min live

Four operations account for most of the damage done to a role model after it is built: granting a role to a role in the wrong direction, never deleting anything, granting to PUBLIC by reflex, and leaving the shared role in place because splitting it feels like admin. The last one is the expensive one, and the bench puts a number on it.

LiveGranting a role to a role, and which way the privileges travel4 min▶

Privileges inherit upward. Grant role A to role B and B holds A's privileges, not the other way round. The session 1 diagram covers the model; what matters here is the sentence you say to yourself before typing, because the statement reads naturally in the wrong direction.

grant role fr_support_agent to role fr_analyst_eu sounds like it gives support something. It gives the analyst everything support can see: name, email, phone and support_notes in clear, across both of support's regions. The analyst design asked for four non-sensitive columns. That one statement is the difference.

Real world

An engineer at Saltbox was asked to let the support lead see the analyst dashboards and typed the grant the way it reads in English. It ran without error, nobody's dashboard changed visibly, and it was found four months later by an access review looking for exactly this shape. The support lead had gained nothing. Every EU analyst had gained the contact columns.

The structural defence is the two-layer rule. Functional roles are not granted to each other, ever. When a job genuinely needs what another job has, the answer is to grant it the same access role, which is one extra keystroke and leaves a reviewable trace in the right layer. A functional-to-functional grant in a show grants output is then unambiguously a mistake rather than something that has to be reasoned about.

LiveNobody deletes a role, and the safe way to do it anyway4 min▶

Role lists only grow, and the reason is rational: dropping a role might break something invisible, and the reward for being right is nothing. So the orphans accumulate, and every one of them is a standing privilege waiting for somebody to be granted it by mistake.

The procedure below makes deletion boring enough to actually happen. It takes about a sprint of wall-clock time and roughly twenty minutes of work.

  1. Find the members. show grants of role lists the users and roles holding it. If it is genuinely empty, you are already most of the way there.
  2. Find what it actually used. Query the account usage history for queries run under that role over the last 90 days. A role with members and no queries is dormant, which is a different and more interesting finding.
  3. Re-point. If it is in use, grant the members the correct functional role first. Nobody should lose access at the moment of deletion; that is what turns a cleanup into an incident.
  4. Revoke, do not drop. Revoke the role from every member and leave the role in place. It is now inert and instantly restorable.
  5. Wait a sprint. Two weeks catches the monthly report, the quarterly close job and the person who was on leave. This is the step everybody skips.
  6. Then drop it. With a commit message saying what it was, who held it and what they hold now, so the next person to find the gap has an answer.

Query history is evidence, not proof. A role with no queries in 90 days may serve an annual regulatory extract. Check the calendar as well as the log before dropping anything, and prefer the long revoke window over a clever argument.

LivePUBLIC and the accidental grant3 min▶

PUBLIC is granted automatically to every user, including the ones who have not been hired yet. A grant to PUBLIC is therefore not a grant to the current team; it is a standing commitment about everybody who will ever have an account, and it will not be re-examined when the contractor arrives in March.

It gets used because it is the fastest way to unblock somebody, and because a shared reference table genuinely does feel like it should be readable by all. Two things make it dangerous in a two-layer model specifically.

  • It bypasses both layers. A privilege on PUBLIC is held by every functional role without appearing in any of their grant lists in a way a reviewer would notice, so the design and reality diverge silently.
  • It is inherited into everything. PUBLIC sits at the bottom of the hierarchy, so its privileges travel up into every role above it, which is every role.

The workable position: allow PUBLIC to hold usage on the database and nothing else, and check for grants to it on a schedule. If a table really is readable by everyone, make an access role called ac_reference_read and grant it to every functional role. It is more typing and it is visible in the review, which is the entire difference.

LiveWhat the role split is actually worth3 min▶

Session 8 scores the finished policy set against 150 concrete access requests over five jobs, ten columns and three regions. The ladder starts from one shared analyst role and adds one control at a time. The row that matters for tonight is the fourth.

Policies onLeaksOver-blocksStanding privileges
One shared analyst role109030
+ mask national_id for everybody94025
+ mask email and phone, except support70017
+ split the shared role five ways2201
+ row access policy on region301
+ pseudonymise the modelling view000

Two masking policies, both real work, take leaks from 109 to 70. Splitting the shared role five ways takes them from 70 to 22 in one move, and takes standing privileges from 17 to 1. Nothing else on the ladder moves standing privileges at all, because a standing privilege is a column a role can read in clear that the design does not mark clear for that role, and while five jobs share one role every one of them is over-granted by definition.

That is the argument for spending tonight on roles rather than on something that demonstrates better. Role modelling does the most work and gets the least attention, because it reads as administration.

Self-studyHow many roles is too many4 min▶

The two-layer model adds roles on purpose, which raises the obvious question of where it stops being a model and starts being sprawl with better prefixes. Four signals are worth watching.

  • Functional roles should track jobs, and jobs are countable. If you have more functional roles than distinct job descriptions, some of them are people, and people-shaped roles never get revoked.
  • Access roles should track data bundles, not requests. An access role created for one request that one job made is a grant wearing a costume. Fold it into an existing bundle or accept that the bundle was wrong.
  • Two roles with identical grants are one role. Worth a scheduled check, because these appear during migrations and nobody notices while both are in use.
  • A role held by nobody for a quarter is a candidate. Not automatically wrong, but it goes on the list and gets the deletion procedure rather than being ignored for another year.

A useful sanity number: for one table with four classification tiers and five jobs, three to four access roles and five functional roles is right. If your first pass produces twenty, the bundles are too fine. If it produces two, at least one role is doing several jobs and the bench has already told you what that costs.

Build-along

Build the five roles

★ 20 min · everyone builds

The statements below are Snowflake's; the shape carries to the equivalents elsewhere. Run them against a scratch database rather than production, and keep the design grid from session 1 open next to you, because step seven is a diff against it.

Create the three access roles. Comments are not decoration here: the comment is what a reviewer reads when the name is not enough, and it is the only field that can carry the design decision.

★ Try it now use role useradmin; create role if not exists ac_customers_read comment = 'customer_id, region, plan on saltbox.analytics.customers'; create role if not exists ac_customers_pii_read comment = 'name, email, phone, support_notes. Support agent only, per design v1'; create role if not exists ac_customers_finance_read comment = 'mrr. Analyst, scientist and finance, per design v1';

Give each access role its column bundle. A grant is table-shaped rather than column-shaped, so a column subset is expressed as a view over the base table. Session 4 does the same job with masking policies on the table itself, which keeps one object instead of three; both are legitimate and the view is easier to read tonight.

★ Try it now use role sysadmin; create or replace view saltbox.analytics.customers_base as select customer_id, region, plan from saltbox.analytics.customers; create or replace view saltbox.analytics.customers_contact as select customer_id, name, email, phone, support_notes from saltbox.analytics.customers; create or replace view saltbox.analytics.customers_finance as select customer_id, region, mrr from saltbox.analytics.customers;

Grant the objects to the access roles. Usage on the database and schema is needed on every level above the object, which is the nesting from session 1. Miss it and the grant looks correct and returns nothing.

★ Try it now use role securityadmin; grant usage on database saltbox to role ac_customers_read; grant usage on schema saltbox.analytics to role ac_customers_read; grant select on view saltbox.analytics.customers_base to role ac_customers_read; grant usage on database saltbox to role ac_customers_pii_read; grant usage on schema saltbox.analytics to role ac_customers_pii_read; grant select on view saltbox.analytics.customers_contact to role ac_customers_pii_read; grant usage on database saltbox to role ac_customers_finance_read; grant usage on schema saltbox.analytics to role ac_customers_finance_read; grant select on view saltbox.analytics.customers_finance to role ac_customers_finance_read;

Create the five functional roles. One per job in the design, named for the job. No object privileges go anywhere near these.

★ Try it now use role useradmin; create role if not exists fr_analyst_eu comment = 'EU analyst'; create role if not exists fr_support_agent comment = 'Support, EU and UK'; create role if not exists fr_data_scientist comment = 'Modelling, all regions'; create role if not exists fr_finance comment = 'Finance, all regions'; create role if not exists fr_contractor_apac comment = 'Contractor, APAC only';

Grant access roles to functional roles. This is the wiring that makes the model a model. Read each line against the design grid as you type it, and if a line does not correspond to a row in the grid, stop, because you are inventing policy at the keyboard.

★ Try it now use role securityadmin; grant role ac_customers_read to role fr_analyst_eu; grant role ac_customers_finance_read to role fr_analyst_eu; grant role ac_customers_read to role fr_support_agent; grant role ac_customers_pii_read to role fr_support_agent; grant role ac_customers_read to role fr_data_scientist; grant role ac_customers_finance_read to role fr_data_scientist; grant role ac_customers_read to role fr_finance; grant role ac_customers_finance_read to role fr_finance; grant role ac_customers_read to role fr_contractor_apac;

Grant functional roles to users. One each. If somebody needs two, that is a finding rather than a configuration: either the design is missing a job or the person is doing two, and both answers are worth having in writing.

★ Try it now grant role fr_analyst_eu to user mara; grant role fr_support_agent to user sam; grant role fr_data_scientist to user iris; grant role fr_finance to user kofi; grant role fr_contractor_apac to user lin;

Verify against the design, one role at a time. Run the statement below for each of the five functional roles and read the output next to the grid. You are checking two things: that every access role the design implies is present, and that nothing else is. The second check is the one that finds problems.

★ Try it now show grants to role fr_analyst_eu; show grants to role fr_support_agent; show grants to role fr_data_scientist; show grants to role fr_finance; show grants to role fr_contractor_apac; select "privilege", "granted_on", "name", "granted_by" from table(result_scan(last_query_id()));

Capture that output to a file in the repository next to the design. Session 7 turns exactly this diff into a pipeline, and having last month's file is what makes the first run useful instead of merely correct.

Now make the mistake on purpose. Grant one functional role to another and look at what the analyst can suddenly read. Predict the answer out loud before you run the second statement.

★ Try it now grant role fr_support_agent to role fr_analyst_eu; show grants to role fr_analyst_eu; use role fr_analyst_eu; select * from saltbox.analytics.customers_contact limit 5;

The analyst now reads name, email, phone and support_notes in clear, across both of support's regions, and the support agent gained nothing. The privileges travelled up to the role above. Nothing in the design changed and nothing errored.

✗ Then put it back use role securityadmin; revoke role fr_support_agent from role fr_analyst_eu;

Practise the deletion procedure on the role you just made a mess with. Find the members, check the query history, then revoke rather than drop. The habit is the deliverable.

★ Try it now show grants of role fr_contractor_apac; select role_name, user_name, count(*) as queries from snowflake.account_usage.query_history where role_name = 'FR_CONTRACTOR_APAC' and start_time >= dateadd(day, -90, current_timestamp()) group by 1, 2 order by queries desc;

Put every statement above in version control before you run it. A role model that exists only as executed statements cannot be diffed, reviewed or rebuilt, and the first question after any incident is what changed and when. The file is also the input to session 7, so writing it now costs nothing later.

Homework

Before session 3

40 min
  1. Build the two layers for one real table in your own estate, with the naming convention written down next to the design.
  2. List every role currently granted directly to a user that is not a functional role. Each one is a hole in the model, and the list is usually shorter than people fear.
  3. Find every functional-to-functional role grant you already have. Work out, for each, what the upper role gained, and check whether anybody intended it.
  4. Find every grant made to PUBLIC. For each, decide whether it should become an access role granted explicitly, or be revoked.
  5. Pick one orphan role and run the deletion procedure as far as the revoke. Diary the drop for two weeks out, and note what turned up during the wait, because something usually does.
  6. Count your roles against your distinct job descriptions. If roles outnumber jobs by more than about two to one, bring the list to session 3.
Check yourself

Three questions

You run grant role fr_support_agent to role fr_analyst_eu. Who can read name, email, phone and support_notes afterwards?

The statement reads as though it gives support something and it does the opposite. Privileges travel up to the role above, which is why functional roles are never granted to each other and why this shape in a grants dump is unambiguously a mistake.

A new table arrives that three of the five jobs need. What does the two-layer model require?

Adding an access role keeps the object privileges in the layer built to hold them. Granting the table straight to functional roles is the shortcut that rebuilds a single-layer model one table at a time, and it hides the grant from the layer a reviewer reads.

You have found a role with three members and no queries in 90 days. What is the safe next move?

Nobody should lose access at the moment of deletion, and query history is evidence rather than proof: a dormant role may serve an annual extract. The revoke window is what turns a cleanup into a reversible change.

Sources covered

What tonight maps to

✓ Functional roles versus access roles, and the two-layer pattern Three access roles and five functional roles built and wired against the written design, with the naming convention and the rule that a user holds only functional roles.
✓ What granting a role to a role really does Privileges inherit upward to the role above, demonstrated deliberately, plus PUBLIC as the grant that reaches every role in the hierarchy.
✓ Role lifecycle and safe deletion Members from show grants, usage from query history, re-point, revoke, wait a sprint, drop. Plus the four signals that a role list has become sprawl.
◐ Row access policies and masking policies Three functional roles here hold identical access roles and differ only on rows and on one masked column. Sessions 3 and 4 build both, in that evaluation order.
◐ RBAC compared with ABAC, per NIST SP 800-162 Tonight is RBAC done properly. The attribute model, and where role hierarchies stop being expressive enough, belong to the leader track's second session.
· Authentication, SSO and MFA Not covered by design. Who a person is, and how they proved it, is upstream of every role they hold.

Session 2 cheat sheet

Two layersAccess roles hold object privileges. Functional roles hold access roles. Users hold functional roles only.
Name by layerac_ for access, fr_ for functional. A role list is what a reviewer actually reads.
Inheritance is upwardgrant role a to role b gives b what a has. The statement reads the other way round.
Never fr to frIf a job needs what another job has, grant it the same access role, not the other job.
Reorg costJob changes: re-point one role. New table: one access role. Person moves: one revoke, one grant.
Safe deletionMembers, query history, re-point, revoke, wait a sprint, drop with a message.
PUBLICHeld by everyone including next month's contractor, and inherited into every role above it.
The payoffSplitting the shared role five ways: leaks 70 to 22, standing privileges 17 to 1.