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.
★ 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.
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.
Mask by default, except by name
10 min liveA 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.
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▶
| Shape | Returns | Who it serves |
|---|---|---|
| Full mask | A 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 mask | The 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 token | A 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.
Order of evaluation, and the one-policy-per-column rule
7 min liveTwo 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.
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.
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 on | Leaks | Over-blocks | Standing privileges |
|---|---|---|---|
| One shared analyst role | 109 | 0 | 30 |
| + mask national_id for everybody | 94 | 0 | 25 |
| + mask email and phone, except support | 70 | 0 | 17 |
| The whole design + strip every PII column | 0 | 8 | 0 |
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.
Three masking policies on saltbox.analytics.customers
★ 20 min · everyone buildsStart 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.
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.
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.
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.
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.
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.
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.
Before session 5
30 min- 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.
- 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.
- 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.
- 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.
- 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.
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.