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

Column security and dynamic masking

A grant on a table hands over every column, and the interesting columns are exactly the ones most roles should not read in clear. Revoking them breaks the one team that needs them; leaving them leaks to everybody else. A dynamic masking policy is the third answer: the column stays in the table, every query still returns it, and what comes back depends on who asked. Tonight is how a masking policy is written so the exception role lives inside it, the three shapes a mask can take, the classification tags that attach one policy to many columns, and the two rules about how masking meets the row policy from session 3.

🟡 Core Analytics and platform engineers 45 min
0-3 · Welcome 3-20 · Masking policies and exception roles 20-40 · Build-along: three masking policies 40-45 · Q&A
Part 0

Where session 4 sits in the ladder

The written design from session 1 marks three of the ten columns on saltbox.analytics.customers as needing a mask for somebody: national_id for everyone, email and phone for everyone except the support agent. Session 2 gave each job a role, session 3 filtered rows by region. Tonight puts the masks on, and it is deliberately taught after the row policy because the order those two are evaluated in is fixed and it decides which column you are allowed to mask at all.

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

★ What you walk out with tonight. Three masking policies on a real table: one with no exception role, because nobody has a job that needs a national ID in clear, and two with a single named exception, because support has to reach the actual human. The habit of writing the exception into the policy rather than granting around it afterwards. And the two rules that govern how masking meets a row access policy, learned by trying to break one of them and reading the error.

LiveWhy revoke and grant are both the wrong tool for a sensitive column3 min▶

A column has two audiences on any real table. Most roles need to know the column exists, need it to stay in the row for joins and counts, and have no business seeing the value. One or two roles need the value to do their job. A grant cannot express that split: it hands the whole column to a role or it does not.

Masking expresses it exactly. The policy is a function of the value and the caller. For most callers it returns a transformed value; for the named exception it returns the real one. The column never leaves the table, nobody's query breaks, and the data scientist's churn model still has a row with an email column in it, just not one that identifies a person.

Real world

Before masking, Saltbox handled the email column the way most teams do: it was in the table, so everyone with SELECT saw it. The analyst dashboards did not use it, the finance reports did not use it, the churn model did not use it, and every one of those roles could still export it. The bench counts that as leaks in every region for four of the five jobs, and none of those four had done anything wrong.

Self-studyThe vocabulary for tonight3 min▶
  • Dynamic masking policy - a schema-level object holding an expression over a column value. Attached to a column, it transforms the value at query time depending on who is asking. The stored data is untouched.
  • Exception role - a role the policy names as receiving the real value. Written into the policy body, so removing the exception is a policy change with a diff, not a grant somebody forgets.
  • Full mask - a fixed string or NULL in place of the value. The column exists; it says nothing.
  • Partial mask - part of the value survives: the last four digits, the email domain, the year of a date. Enough to recognise, not enough to identify or contact.
  • Hash or token - a deterministic replacement that keeps equal inputs equal, so the column still joins and groups. Session 5 takes this shape further.
  • Classification tag - a label on a column that says what class of data it holds. A policy bound to the tag applies to every column carrying it.
Part 1 · covers masking policies, exception roles, masking shapes and tags

Mask by default, except by name

10 min live

A masking policy has one design decision in it, and it is not the mask. It is the exception list. A policy that masks for everybody and nobody is exempt is a statement that no job needs the value, and it should be rare and deliberate. A policy with one named exception is a statement that exactly one job does, and the name is the justification. A policy with six exceptions is a grant wearing a disguise.

Two masking policies, and the exception list is the design Gate 1 · national_id every role MASK always ***-**-**** No exception role. Nobody has a job that needs it, and the absence of an exception is the point. Gate 2 · email and phone four other roles MASK unless named *****@saltbox fr_support_agent real value One exception role, written into the policy body. Support has to reach the actual human. On the shared role, before any role split: 109 leaks mask natl_id 94 leaks mask email, phone 70 leaks 0 over-blocks at every step, because the exception is inside. Over-blocks appear only when every PII column is stripped instead: 0 leaks, 8 over-blocks.
🔍 Click to zoom - one gate with no exception, one with a single name, and 109 to 94 to 70 beneath.
LiveWhat a masking policy is, and where the exception goes4 min▶

A dynamic masking policy is a schema-level object that takes a column value and returns a value of the same type. Attached to a column, it runs at query time for every row that reaches it, and the caller's roles are available inside the expression. The data on disk never changes; only what the query returns does. Two callers running the same SELECT get two different answers, and both are correct.

The exception role belongs inside the policy body, as a branch of the expression: if the session holds the named role, return the value, otherwise return the mask. The alternative, masking for everyone and then handing the exception role a separate unmasked view or a grant on a copy, is how exceptions multiply. Each one is a new object with its own grants, none of them show up when you read the policy, and the access review in session 7 finds them one at a time. An exception in the policy body is one line in one place, and removing it is a diff somebody reviews.

Use IS_ROLE_IN_SESSION for the exception, for the same reason as the row policy. A support agent whose primary role is a reporting role and whose fr_support_agent is active as a secondary role would be masked by a CURRENT_ROLE() check and would open a ticket saying the phone numbers are broken.

LiveThree masking shapes, and which job each one serves3 min▶
ShapeReturnsWho it serves
Full maskA fixed string or NULL. The column is present and says nothing.Anybody who needs the row and never the value. national_id for every role.
Partial maskThe last four digits, the email domain, the year of a date of birth.Roles that need to recognise or bucket without identifying. An analyst counting customers by email domain.
Hash or tokenA deterministic replacement. Equal inputs stay equal, so joins and GROUP BY survive.Modelling. The data scientist needs a stable customer key and never the customer. Session 5.

Pick the weakest shape that still does the job the design describes, and write down why. A partial mask that leaves the last four digits of a phone number is a design decision about how many digits an attacker needs, not a formatting choice. The email domain is safe to leave for a plan-mix analyst and not safe to leave for a table of two hundred customers where one of them works at a company of three.

LiveThe two Saltbox policies, and why one of them has no exception3 min▶

The written design produces exactly two masking policies for this table, and they are different in the one way that matters.

  • national_id, masked for everybody, no exception. Read the design grid: no job at Saltbox needs a national ID in clear, or masked, or at all. The policy therefore has no exception branch, and that absence is the control. The first time somebody asks for one, the answer is not a grant but a change to the design, argued in the open.
  • email and phone, masked for everybody except fr_support_agent. Support answers tickets and has to reach the actual human. That is the one job with a stated reason, so it is the one name in the policy. The EU analyst, the data scientist, finance and the contractor all get the mask, and none of their work needs the value.

On the bench, with the single shared role still in place, masking national_id takes leaks from 109 to 94 and masking email and phone with the support exception takes them to 70. Over-blocks stay at 0 at both steps, because the support agent's row stays clean: the exception is inside the policy, so nothing was taken away from the one job that needed it. Standing privileges fall 30 to 25 to 17 over the same two steps.

Self-studyClassification tags: one policy, many columns3 min▶

Attaching a policy column by column works for one table with ten columns. It does not work for a warehouse with four thousand columns of which three hundred are contact details, because the policy is only as complete as the last person's memory of which columns exist. The cross-platform answer is to attach the policy to a classification tag instead, and to tag columns by what class of data they hold.

  • The pattern: a tag such as contact_pii is applied to email, phone and every other column of that class. One masking policy is bound to the tag. Every tagged column is masked by that policy, and a newly tagged column is masked the moment the tag lands.
  • The equivalents: BigQuery calls the mechanism policy tags and pairs it with column-level security. Snowflake and Databricks Unity Catalog both offer tag-based masking. The category is the same; the syntax differs and is worth reading from each platform's own reference rather than a summary.
  • The dependency it creates: classification becomes the input that decides which column gets masked. Mis-tag a column and it is mis-masked, which is why the classification tiers from session 1 are kept to three or four and why free-text columns are tagged by what they can contain.
Part 2 · covers evaluation order and the one-policy-type-per-column rule

Order of evaluation, and the one-policy-per-column rule

7 min live

Two rules, both documented, and between them they decide the shape of any policy set that has rows and columns to protect. Where a table carries both a row access policy and masking policies, the row access policy is evaluated first. And a column cannot carry a row access policy and a masking policy at the same time.

Grants, then the row policy, then masking. The order is fixed. 1 · Grants Which columns the role may SELECT at all. fr_analyst_eu holds: customer_id region plan mrr 2 · Row access policy region_map decides which rows survive. Rejected rows go no further. keyed on: region 3 · Masking policies Transform values in the rows that arrived, per the caller's roles. national_id, email, phone region already carries the row access policy. One policy type per column, so it cannot also carry a masking policy. Filter rows on a coarse, non-sensitive column, because that column stays in clear for every role. A rejected row never reaches stage 3. A masked value was already allowed through stages 1 and 2.
🔍 Click to zoom - rows are filtered first, then what remains is masked, and the keying column is off limits.
LiveWhat the two rules do to your design4 min▶

The evaluation order means filter rows by region first, then mask what remains. A masking policy never runs on a row the row policy rejected, so the cost of masking scales with the rows a role is allowed to see, and a role allowed to see nothing costs nothing. It also means a masking policy cannot be used to hide the value the row policy needs, because by the time masking runs, the row decision has already been made on the real value.

The one-policy-type rule closes the loop: the column the row policy keys on cannot itself be masked. For Saltbox that is region, and it is fine, because region is non-sensitive and every one of the five jobs needs it in clear. The rule bites when a team wants to filter on something sensitive, a national ID prefix or a full postcode, and also mask it. The answer is to derive a coarse, non-sensitive column for the filter and leave the sensitive one to masking.

Real world

A Saltbox engineer once proposed keying the row policy on the customer's country of citizenship taken from the national_id column, so that a single column would both filter and get masked. It cannot be done, and the design was better for it: region became an explicit column populated at load time, the row policy keyed on that, and national_id kept its mask with no exception.

LiveWhat the bench says: 109 to 94 to 70, and where the over-blocks really come from3 min▶

The policy bench in session 8 evaluates 150 access requests, of which 41 need a value in clear, 3 need it masked and 106 have no business being answered. The two masking policies tonight are the first two rungs of its ladder, applied on top of the single shared analyst role.

Policies onLeaksOver-blocksStanding privileges
One shared analyst role109030
+ mask national_id for everybody94025
+ mask email and phone, except support70017
The whole design + strip every PII column080

Two things to read off that table. First, over-blocks stay at 0 through both masking steps, because the exception role for support is inside the contact policy and the support agent's row stays clean. Second, the over-blocks only appear when somebody strips every PII column from everyone instead of masking: 0 leaks, 8 over-blocks, all of them the support agent across name, email, phone and support_notes in each of the two regions they cover. Masking with a named exception and stripping the column both satisfy a leaks-only report. Only one of them lets the support desk close a ticket.

Build-along

Three masking policies on saltbox.analytics.customers

★ 20 min · everyone builds

Start from where session 3 left the table: the five functional roles hold SELECT and the region row policy is attached. Steps one to four build the masks and compare two roles. Step five tries to break the one-policy-type rule and reads the error. Step six runs the alternative that satisfies the auditor and counts who can no longer work.

Create the national_id policy: a fixed mask for every role, no exception branch.

★ Try it now create masking policy saltbox.analytics.mask_national_id as (val varchar) returns varchar -> '***-**-****';

There is deliberately no CASE here. A policy body with no branch is a policy with no exception, and anybody reading it later can see that at a glance.

Create the contact policy with a CASE on IS_ROLE_IN_SESSION('fr_support_agent'). One policy, attached to both email and phone.

★ Try it now create masking policy saltbox.analytics.mask_contact as (val varchar) returns varchar -> case when is_role_in_session('FR_SUPPORT_AGENT') then val else '*** masked ***' end;

The role name is upper-case because that is how an unquoted identifier is stored, and a case mismatch here silently masks the support agent too. If the design calls for a partial mask on email, a shape that keeps the domain is regexp_replace(val, '^[^@]+', '*****') in place of the fixed string; keep the exception branch identical.

Attach all three with ALTER TABLE ... MODIFY COLUMN ... SET MASKING POLICY.

★ Try it now alter table saltbox.analytics.customers modify column national_id set masking policy saltbox.analytics.mask_national_id; alter table saltbox.analytics.customers modify column email set masking policy saltbox.analytics.mask_contact; alter table saltbox.analytics.customers modify column phone set masking policy saltbox.analytics.mask_contact;

Three attachments, two policies. The contact policy is reused on both columns, which is the small-scale version of what a classification tag does across a whole warehouse.

Query as fr_support_agent and as fr_analyst_eu, and compare.

★ Try it now use role fr_support_agent; select customer_id, name, email, phone, national_id, region from saltbox.analytics.customers where region = 'EU' limit 5; use role fr_analyst_eu; select customer_id, name, email, phone, national_id, region from saltbox.analytics.customers where region = 'EU' limit 5;

Support sees real email and phone and a masked national_id, because support is the named exception on one policy and nobody is an exception on the other. The EU analyst sees three masked columns. Both roles see only EU rows, because the row policy from session 3 ran first. Same table, same statement, two correct answers.

Try to attach a masking policy to region, the column the row policy already uses.

★ Try it now alter table saltbox.analytics.customers modify column region set masking policy saltbox.analytics.mask_contact;

The statement fails. Read the error rather than skimming past it: region carries the row access policy, and a column cannot carry both a row access policy and a masking policy at the same time. Nothing to fix here; the failure is the rule working. It is also the moment to notice that region is in clear for every role, and to check the design agrees that it should be. It does.

Now run the "strip everything" alternative and count who can no longer work. Column-level access here is done by view, so stripping the PII columns means pointing every role at a view without them.

★ Try it now create view saltbox.analytics.customers_no_pii as select customer_id, region, plan, mrr from saltbox.analytics.customers; revoke select on table saltbox.analytics.customers from role fr_support_agent; grant select on view saltbox.analytics.customers_no_pii to role fr_support_agent; use role fr_support_agent; select name, email, phone, support_notes from saltbox.analytics.customers_no_pii where region = 'EU';

The last statement fails, because the columns are not there. Count what support has just lost: name, email, phone and support_notes, in each of EU and UK, which is 8 things the design marked clear for that job and that job alone. That is the 8 over-blocks the bench reports for the strip-everything row, and it is the number a leaks-only audit never shows. Restore the grant and drop the view before you leave.

Write the exception's reason next to the policy. A comment on the policy object, or a line in the design grid, saying "support has to reach the actual human". The access review in session 7 will ask why fr_support_agent sees phone numbers, and a policy that answers the question itself is a policy that survives the review without a meeting.

Homework

Before session 5

30 min
  1. Attach a masking policy to one real sensitive column on a table you own. Write it with no exception first and see who complains within a day. Each complaint is either a candidate exception with a reason, or a job that never needed the value.
  2. For every existing exception to a mask in your estate, find where it lives: inside the policy body, or as a separate view or grant. Count the ones outside the policy. Those are the ones the access review will find one at a time.
  3. Pick one column that somebody wants both filtered on and masked. Design the coarse, non-sensitive column that takes over the filtering, and say what populates it.
  4. List your contact-class columns across the warehouse without looking at any policy. Then compare against what is actually masked. The gap is the argument for tag-based masking.
  5. Find every column that is currently unmasked because a role needed to join on it. Each one is a candidate for the hash or token shape, which is session 5.
Check yourself

Three questions

A table carries a row access policy on region and masking policies on email and phone. A row for an APAC customer is requested by fr_analyst_eu. What happens to its email value?

Row access policies are evaluated before masking policies. A rejected row never reaches the masking stage, which is why masking cost scales with the rows a role may see.

The row policy is keyed on region. A reviewer asks for region to be masked as well. What is the correct answer?

One policy type per column. The filtering column is off limits to masking, which is the reason to filter on a coarse, non-sensitive attribute in the first place.

Support needs real phone numbers. Where does the exception belong?

An exception inside the policy is one line in one place with a diff. A separate view is a second object with its own grants that the policy does not mention, and option C is how the numbers end up in a ticketing system with no masking at all.

Sources covered

What tonight maps to

✓ Dynamic masking policies with exception roles The policy object, the exception written into the body with IS_ROLE_IN_SESSION, and the three masking shapes with the job each serves.
✓ Row-policy-first evaluation order Where a table carries both, the row access policy is evaluated first, so masking only ever runs on rows that survived.
✓ One policy type per column A column cannot carry a row access policy and a masking policy at once, demonstrated by attempting it on region and reading the error.
✓ Classification tags as the attachment mechanism Policy tags in BigQuery and tag-based masking generally, named as the cross-platform category. Syntax is left to each platform's reference rather than summarised.
◐ Tokenisation and pseudonymisation The hash or token shape is named as the third mask. Session 5 builds surrogate keys and takes the data scientist's customer_id from a leak to a pseudonym.
· Data protection law and consent Not covered by design. Which columns count as personal data is decided by a separate discipline; this is the machinery that masks them once decided.

Session 4 cheat sheet

Masking policySchema-level. (val type) RETURNS type then the expression. Runs at query time; data on disk unchanged.
Exception inside the bodyCASE WHEN IS_ROLE_IN_SESSION('role') THEN val ELSE mask. Never a separate view or grant.
Three shapesFull mask, partial mask, hash or token. Pick the weakest that does the job and write down why.
No exception is a controlnational_id has none because no job needs it. The first request for one is a design change, not a grant.
Row policy firstEvaluated before masking. A rejected row is never masked; masking cost follows visible rows.
One policy type per columnThe row policy's keying column cannot be masked. Filter on something coarse and non-sensitive.
Tags scale itBind the policy to a classification tag and every tagged column inherits it. Mis-tag means mis-mask.
The bench109 to 94 to 70 leaks, 0 over-blocks throughout. Strip every PII column instead: 0 leaks, 8 over-blocks.