Where tonight sits in the track
Sessions 1 to 4 built grants, a two-layer role hierarchy, a row access policy on region and masking policies on the contact and identity columns. On the bench that leaves 3 leaks, and all three are the same cell: fr_data_scientist reading a real customer_id where the design asked for a pseudonym. Tonight closes that cell with a modelling view, which is the step that takes the bench to 0 leaks, 0 over-blocks and 0 standing privileges. Session 6 then protects the one secret this whole control depends on.
★ What you walk out with tonight. The difference between a mask and a token, stated so you can defend it to a data scientist and to an auditor in the same sentence. Three ways to make a surrogate key and the one way that only looks like it works. A modelling view over customers that joins, models and cannot be reversed by the role that reads it. And a query that finds the segments of one, which is where the real re-identification risk lives.
LiveThe cell that the last three leaks live in3 min▶
The written design from session 1 gives the data scientist region, plan and mrr in clear and customer_id masked. Every other cell for that job is empty. A churn model trained on that is fine: it needs behaviour per customer and a segment per customer, and it does not need to know that customer 48213 is a person called Mara in Lisbon.
What the model does need is for customer 48213 to have the same key in the customers table, the events table and the payments table. A masking policy that returns ******** destroys that, because every row now has the same key. A masking policy that returns the last four characters gives a key that collides across thousands of customers. So the masked cell in the design is not asking for a mask at all; it is asking for a substitute that behaves like the real key in a join and like nothing in a lookup.
Saltbox's first attempt was to give the modelling team the real customer_id "because it is just a number". Six months later a notebook in a shared folder joined that number to a marketing export with names in it, to check a hunch. The join took one line. Nobody had done anything they were not allowed to do, which is the point: the control was missing, not the intent.
Self-studyThe vocabulary for tonight3 min▶
- Masking - transforming a column value at query time into something that hides it. Fixed strings, partial values, nulls. Session 4 built these.
- Tokenisation - replacing a value with a substitute, the token, that stands in for it consistently. The same input gives the same token, so tokens join.
- Surrogate key - a token used specifically as an identifier for joining. What the modelling view exposes instead of customer_id.
- Pseudonymisation - processing personal data so it can no longer be attributed to a person without additional information, which is kept separately. Still personal data, because somebody holds the additional information.
- Anonymisation - processing so that nobody, including the original holder, can re-link the data to a person. Much harder than it sounds, and the second half of tonight is about why.
- Direct identifier - a column that names a person on its own: name, email, phone, national_id.
- Quasi-identifier - a column that does not identify anybody alone but does in combination: dob, region, plan, a join date.
A mask hides, a token substitutes
9 min liveBoth controls transform a value before the reader sees it. The difference is whether the transformed value still behaves like a key. A mask is designed so that it does not. A token is designed so that it does, for everybody, while only the holder of a secret can walk it back to the original.
LiveThree ways to make a surrogate, and what each one costs4 min▶
Every tokenisation scheme is a deterministic function from the real identifier to a substitute, plus some arrangement for who can reverse it. Three shapes cover almost every warehouse.
| Shape | How it works | Reversible by | Cost |
|---|---|---|---|
| Keyed hash | HMAC over the identifier with a secret key. Same input and key, same output, every time | Nobody directly. Whoever holds the key can re-compute and match | Cheapest. One secret to protect, no extra table |
| Lookup vault | A separate table mapping real value to a random surrogate, written once | Whoever can read the vault table | A second object with its own grants, and a write path to secure |
| Format-preserving token | A token with the same shape as the original: 16 digits for a card number, a valid-looking phone number | Whoever holds the tokenisation key | The only option when a downstream system validates the shape of the value |
For a modelling view the keyed hash wins, because a surrogate key has no format to preserve and no downstream system to satisfy. The vault earns its place when values must be reversed for a business reason, such as a collections team that needs to reach the real customer from a flagged surrogate. The format-preserving token is a payments-system control and almost never belongs inside an analytics warehouse.
LiveWhy an unkeyed hash is not pseudonymisation3 min▶
The tempting shortcut is sha2(national_id) with no key. It looks irreversible, because a hash cannot be computed backwards. It is reversible in practice within minutes, because nobody needs to compute it backwards: they compute it forwards for every possible input and look up the match.
- A national ID space is small. A few hundred million valid values at most for one country, and often far fewer once the check digit and date structure are respected. Hashing all of them is a lunch-break job on a laptop.
- A phone number space is smaller. One country code, a known set of prefixes, seven to nine remaining digits. Hash the whole space once, keep the table forever, reverse any hashed phone number by lookup.
- An email is not much better. Most addresses follow a handful of patterns over a name and a few dozen domains, and the names are already in the table next door.
The secret in a keyed hash is what makes enumeration useless: the attacker would need to hash every candidate with a key they do not have. Take the key away and the hash is a slow way of writing the original value. That is why md5(customer_id) in a modelling view is a real customer_id for bench purposes, and would still count as a leak.
The secret decides who the reader is. Anybody who can read the hashing key can regenerate every token, so the key is exactly as sensitive as the column it protects. Session 6 treats it as a credential, with rotation, an owner and a storage rule, because that is what it is.
LiveOn the bench: pseudonymising the modelling view is the last rung2 min▶
Session 8 scores the finished policy set against 150 concrete requests, and the ladder it climbs ends with tonight's control.
| Policies on | Leaks | Over-blocks | Standing privileges |
|---|---|---|---|
| + row access policy on region | 3 | 0 | 1 |
| + pseudonymise the modelling view | 0 | 0 | 0 |
The 3 leaks before this step are all one cell: the data scientist holding a real customer_id, in each of the three regions they cover, where the design said masked. The 1 standing privilege is the same cell counted the other way. A masking policy on customer_id for that role would also clear the leaks and would break the model. The surrogate key clears them and keeps the join, which is why it is the control that finishes the design instead of a tightening that costs somebody their job.
Self-studyDeterministic or not, and the case for salting per purpose3 min▶
A surrogate key has to be deterministic within one modelling estate, or the joins fail. It does not have to be the same surrogate everywhere.
- One key per purpose. Hashing with a different secret for the churn model and for the fraud team means a token leaked from one cannot be joined to the other. The cost is that the two teams cannot join to each other either, which is often exactly the separation the design wanted.
- One key per period. Rotating the secret annually and re-tokenising means an old export goes stale. Nothing built on it can be joined to fresh data, which limits the life of anything that walked out of the door.
- Never per row. A random salt per row is a random value, not a token, and it destroys the join. This is the mistake people make when they have heard that salting is good and have not asked what it is for.
Decide the granularity before writing the view, because changing it later means re-issuing every surrogate to every consumer at once.
Re-identification, and why a quasi-identifier is as good as a name
8 min liveOnce the direct identifiers are gone, the table looks safe, and the pressure to hand it out widely goes up. It is not safe. A handful of ordinary columns, none of them personal on their own, will single out one row in a small enough segment, and the person who knows one fact about their neighbour can now read all the others.
LiveQuasi-identifiers: three ordinary columns make one name3 min▶
A quasi-identifier is a column that would never be classified as personal on its own and still narrows the population. Date of birth alone matches thousands of people. Region alone matches a third of the table. Plan alone matches a quarter. Together, in a segment with a few hundred customers, they frequently match exactly one.
- The linkage attack is the ordinary case, not the exotic one. The attacker is a colleague, a contractor or a former employee who knows one true fact about one customer: their approximate age, their region, their plan. The table hands them the rest.
- Bucketing helps and does not finish the job. Turning dob into a ten-year band collapses many segments of one into segments of many. Some remain, and the query in the build-along finds them.
- Small regions are the danger. A segment of one in the EU with 60,000 customers is rare. In a country with 400 customers it is the norm, and a regional filter on the view makes the problem worse rather than better for the person reading that region.
LiveThe honest limits of "just make every group bigger than k"3 min▶
The natural response is a rule: no combination of quasi-identifiers may appear fewer than k times, and suppress or generalise the rows that break it. That rule has a name, k-anonymity, and it is a reasonable review check. It is not a guarantee, for two reasons that matter in a warehouse.
- Groups can be uniform. Five people share an age band, region and plan, so the group is safe under k = 5. All five have mrr 1,240 and a support flag for arrears. The attacker learns the sensitive value without needing to know which of the five is their target.
- Tables get joined. A view that is k-anonymous alone stops being so the moment somebody joins it to a second table with a different set of columns. The surrogate key that makes the modelling view useful is exactly what makes that join possible.
The professional conclusion is not to abandon the check. It is that removing names is not anonymisation, that a modelling view is still a view over personal data, and that it therefore still needs the row policy from session 3, a classification review of every column it exposes, and a named owner. The statistics of how much residual risk is acceptable is a separate discipline with its own literature, and the warehouse job is to make sure the question gets asked.
A Saltbox analyst built a "safe" customer extract for an agency: no names, no contact details, dob bucketed to year. The agency's first slide showed the ten highest-revenue accounts by birth year and city. Three of the ten were single-customer cells, and one of them was a board member's company. The extract had passed a review that only checked for direct identifiers.
LiveWhat stays out of the modelling view entirely2 min▶
Some columns cannot be made safe by transformation, because their content is not known in advance. support_notes is the example on this table and every estate has one.
- Free text carries whatever an agent typed. Over a year that includes full names, phone numbers read out and written down, card numbers, medical circumstances offered as a reason for late payment, and once in a while a password. No masking policy can find all of it and no hash can pseudonymise it.
- The design already said no. The data scientist's row in the access design has an empty cell for support_notes. The view simply does not select the column, which is the cheapest control there is: a column that is not in the view cannot leak through the view.
- If the model needs the text, the text needs its own pipeline. Redaction, review, and a separate view with a separate grant. Not a column quietly added to the modelling view because the notebook would be more interesting with it.
Self-studyPseudonymised is not anonymised, and the difference has an owner3 min▶
Two words get used interchangeably in review meetings and describe different things. As a professional distinction, stated without any claim about what a particular law requires:
| Pseudonymised | Anonymised | |
|---|---|---|
| Can somebody re-link it? | Yes, whoever holds the key or the vault | Nobody, including the original holder |
| Is it still personal data? | Yes. It describes a person somebody can identify | No, if the claim genuinely holds |
| Does it need a row policy and a review? | Yes | The review is what establishes the claim |
| Where does it usually sit? | The modelling view built tonight | Published statistics, aggregated and thresholded |
The modelling view is pseudonymised. The hashing key exists, somebody holds it, and with it every surrogate walks back to a customer_id. That is a deliberate design choice, because a model that produces a churn score has to be able to reach the customer it scored. It also means the view stays inside every control that applies to the base table. What a given jurisdiction requires of pseudonymised data, and when a dataset legally counts as anonymised, are questions for the governance and data protection courses; the warehouse job is to be honest about which of the two words applies.
Build the modelling view, prove it joins, then find the segments of one
★ 20 min · everyone buildsYou need the customers table from the earlier sessions, the row access policy on region from session 3, and a role fr_data_scientist that currently holds nothing on the table directly. The hashing function below is written as a UDF so the secret has one home. The exact HMAC function name differs by platform; the shape does not.
Put the secret somewhere the modelling role cannot read. One schema, owned by the platform role, with no grants to any functional role. The UDF that wraps the hash lives in that schema and is the only thing that ever sees the key. Session 6 covers how the key gets in there; for tonight the placeholder stands in for it.
A secure function hides its definition from callers who do not own it, which is what stops the key being read out of the function body. Replace the placeholder call with your platform's keyed-hash function, or with an external function that calls out to the key manager if the warehouse has none.
Create the modelling view. It returns the surrogate as the key, drops every direct identifier and the free-text column, keeps the three columns the design marks clear, and turns dob into a band.
The comment at the end is not decoration. A reviewer reading the view in six months should be able to see that six columns were excluded deliberately rather than forgotten.
Grant the view, and only the view. The modelling role gets USAGE up the object tree and SELECT on the view. It gets nothing on the base table, which is what makes the surrogate the only key it can ever see.
Confirm the row policy still applies through the view. The policy from session 3 is attached to the base table, and a view over that table evaluates it for the querying role. The design gives the data scientist all three regions, so use a role with a narrower scope to see the filter bite, then switch back.
If the second query returns EU rows, stop. Some platforms need the row policy attached to the view as well as the table, and some views declared with owner's rights evaluate the policy for the owner rather than the reader. Whichever it is, a modelling view that ignores the region policy has widened access rather than narrowed it.
Prove joinability. Build the same surrogate over a second table and join on it. The join has to work for the modelling role, with neither table exposing a real customer_id.
Same function, same secret, same surrogate on both sides. This is the property a masking policy could never give you, and it is why the design cell said masked and meant tokenised.
Find the segments of one. Count how many rows share each combination of the quasi-identifiers the view exposes. Every group of size one is a person who can be picked out with three ordinary facts.
Write down how many groups come back under 5 and how many are exactly 1. Then decide, with the owner of the view, whether to widen the age bands, collapse the small plans into "other", or suppress those rows for that role. There is no single correct answer, and the point of the query is that the decision is now being made by somebody instead of by nobody.
The key is now the most sensitive object in the schema. Anybody who can read the function body, or the secret it wraps, can rebuild every surrogate and join the modelling view straight back to names. Write that sentence into the view's comment, because the next person to widen the grants on saltbox.vault will not have been in this room.
Before session 6
30 min- Find every unkeyed hash of an identifier in your estate. Search view definitions and dbt models for
md5(andsha2(over anything that looks like an id, email or phone. Each one is a real identifier with extra steps. - Run the segments-of-one query over your own modelling table with whatever quasi-identifiers it exposes. Record the count of groups under 5 before you change anything, so the next review can show the number moved.
- Write the reversal path down. Who holds the key, which role can read the function body, and what the process is for a legitimate re-link such as a collections case. If the answer is "the platform team, informally", that is the finding.
- Check every view over personal data for free-text columns. Any that carry one needs either a reason written next to it or a one-line change that drops it.
- Ask the modelling team which of their joins would break if the surrogate changed. That list is the blast radius of a key rotation, and session 6 needs it.
Three questions
The data scientist's design cell for customer_id says masked. Why does a masking policy returning ******** not satisfy it?
A mask hides, a token substitutes. The model needs a stable key that joins across tables, which is what a keyed hash provides and a fixed-string mask destroys.
A view exposes sha2(phone) with no key as the customer identifier. How safe is it?
Enumeration does not need to go backwards. Hash every possible number once and the table becomes a reverse dictionary. Only a secret the reader lacks stops that, which is what makes the hash keyed.
A modelling table has had name, email, phone and national_id removed. Which statement is correct?
Quasi-identifiers do in combination what a name does alone. Removing direct identifiers is a start; the segments-of-one query shows how far it falls short.