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

Tokenisation and pseudonymisation

Session 4 left one cell open in the design. The data scientist needs a customer key that is stable enough to join a year of events onto, and has no business knowing whose key it is. A masking policy cannot give them that, because a masked value cannot be joined on. Tonight is the control that can: a substitute key that is deterministic for whoever holds the secret and meaningless to everybody else. Then the uncomfortable part, which is that removing the names from a table does not stop it identifying people, and a modelling view still needs a row policy and a review.

🟠 Advanced Analytics and platform engineers Data scientists who need the row and not the person 45 min
0-3 · Welcome 3-20 · Keys that join without identifying 20-40 · Build-along: the modelling view 40-45 · Q&A
Part 0

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.

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

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

Real world

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.
Part 1 · covers surrogate keys, keyed hashes, vaults and format-preserving tokens

A mask hides, a token substitutes

9 min live

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

The same value, two controls, two very different results customer_id 48213 masking policy returns a fixed string ******** same for every row cannot join every customer now has an identical key keyed hash HMAC(id, secret) tk_7f3a92c1 stable per customer events tk_7f3a92c1 login tk_7f3a92c1 payment tk_c04e11b9 login The secret lives where fr_data_scientist cannot read it. Without it, tk_7f3a92c1 walks back to nothing. A mask answers "hide it". A token answers "let them join it without knowing whose it is".
🔍 Click to zoom - the top path hides, the bottom path substitutes and still joins.
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.

ShapeHow it worksReversible byCost
Keyed hashHMAC over the identifier with a secret key. Same input and key, same output, every timeNobody directly. Whoever holds the key can re-compute and matchCheapest. One secret to protect, no extra table
Lookup vaultA separate table mapping real value to a random surrogate, written onceWhoever can read the vault tableA second object with its own grants, and a write path to secure
Format-preserving tokenA token with the same shape as the original: 16 digits for a card number, a valid-looking phone numberWhoever holds the tokenisation keyThe 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 onLeaksOver-blocksStanding privileges
+ row access policy on region301
+ pseudonymise the modelling view000

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.

Part 2 · covers quasi-identifiers and re-identification risk

Re-identification, and why a quasi-identifier is as good as a name

8 min live

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

Names removed. One row still names somebody. surrogate age_band region plan mrr tk_7f3a92c1 25-34 EU starter 29 tk_c04e11b9 25-34 EU starter 29 tk_2b9d70ae 35-44 UK growth 89 tk_e51f38d2 55-64 APAC enterprise 1,240 tk_91ac5e07 35-44 UK growth 89 segment of one Three columns, none of them personal on their own. Anyone who knows one APAC enterprise customer in their late fifties now knows their revenue. The other rows have company. This one does not. Removing names is a start. It is not anonymisation, and the row policy still has a job to do.
🔍 Click to zoom - dob, region and plan isolate one row with no name in sight.
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.

Real world

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:

PseudonymisedAnonymised
Can somebody re-link it?Yes, whoever holds the key or the vaultNobody, including the original holder
Is it still personal data?Yes. It describes a person somebody can identifyNo, if the claim genuinely holds
Does it need a row policy and a review?YesThe review is what establishes the claim
Where does it usually sit?The modelling view built tonightPublished 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-along

Build the modelling view, prove it joins, then find the segments of one

★ 20 min · everyone builds

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

★ Try it now create schema if not exists saltbox.vault; create or replace secure function saltbox.vault.surrogate_key(id varchar) returns varchar as $$ 'tk_' || left(hmac_sha256_placeholder(id, '<REDACTED>'), 12) $$; -- nothing below the platform role gets to read the body revoke all on schema saltbox.vault from role fr_data_scientist;

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.

★ Try it now create or replace view saltbox.analytics.customers_modelling as select saltbox.vault.surrogate_key(customer_id) as customer_key, case when datediff(year, dob, current_date()) < 25 then 'under 25' when datediff(year, dob, current_date()) < 35 then '25-34' when datediff(year, dob, current_date()) < 45 then '35-44' when datediff(year, dob, current_date()) < 55 then '45-54' when datediff(year, dob, current_date()) < 65 then '55-64' else '65 and over' end as age_band, region, plan, mrr from saltbox.analytics.customers; -- not selected, on purpose: name, email, phone, national_id, dob, support_notes

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.

★ Try it now grant usage on database saltbox to role fr_data_scientist; grant usage on schema saltbox.analytics to role fr_data_scientist; grant select on view saltbox.analytics.customers_modelling to role fr_data_scientist; show grants to role fr_data_scientist; -- expect: the three lines above and nothing on customers itself

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.

★ Try it now use role fr_data_scientist; select region, count(*) from saltbox.analytics.customers_modelling group by region; -- expect: EU, UK and APAC, because the design gives this job all three grant select on view saltbox.analytics.customers_modelling to role fr_contractor_apac; -- as the view owner use role fr_contractor_apac; select region, count(*) from saltbox.analytics.customers_modelling group by region; -- expect: APAC only. If EU rows appear, the view has bypassed the policy and must not ship. revoke select on view saltbox.analytics.customers_modelling from role fr_contractor_apac;

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.

★ Try it now create or replace view saltbox.analytics.events_modelling as select saltbox.vault.surrogate_key(customer_id) as customer_key, event_type, event_at from saltbox.analytics.events; grant select on view saltbox.analytics.events_modelling to role fr_data_scientist; use role fr_data_scientist; select c.customer_key, c.plan, count(e.event_at) as events_90d from saltbox.analytics.customers_modelling c join saltbox.analytics.events_modelling e on e.customer_key = c.customer_key where e.event_at >= dateadd(day, -90, current_date()) group by c.customer_key, c.plan limit 20;

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.

★ Try it now use role fr_data_scientist; select age_band, region, plan, count(*) as n from saltbox.analytics.customers_modelling group by age_band, region, plan having count(*) < 5 order by n, region, plan;

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.

Homework

Before session 6

30 min
  1. Find every unkeyed hash of an identifier in your estate. Search view definitions and dbt models for md5( and sha2( over anything that looks like an id, email or phone. Each one is a real identifier with extra steps.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Check yourself

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.

Sources covered

What tonight maps to

✓ Surrogate keys and keyed hashing Deterministic substitutes that join, why the secret is what makes them pseudonymous, and why an unkeyed hash of a low-entropy field is reversible by enumeration.
✓ Format-preserving tokens and lookup vaults Named with the case each one serves, and why neither is the right shape for an analytics modelling view.
✓ Quasi-identifiers and re-identification risk Linkage through ordinary columns, the segments-of-one query, bucketing, and the honest limits of a minimum group size.
◐ Masking policies and evaluation order Built in session 4. Named tonight only to contrast a mask with a token.
◐ Key storage, rotation and ownership The hashing secret is a credential. Session 6 gives it a home, an owner and a rotation schedule.
· Legal status of pseudonymised and anonymised data Not covered by design. The professional distinction is stated; what any law requires of either belongs to the governance and data protection courses.

Session 5 cheat sheet

Mask versus tokenA mask hides and cannot be joined. A token substitutes and joins. The design cell said masked and meant tokenised.
Keyed hashHMAC over the identifier with a secret the reader cannot see. Cheapest surrogate, one thing to protect.
Unkeyed hashA slow way of writing the original. Phone numbers and national IDs enumerate in a lunch break.
Vault and format-preservingFor reversal on purpose and for systems that validate shape. Rarely inside analytics.
Quasi-identifiersdob plus region plus plan isolates a row with no name anywhere. Run the segments-of-one query.
Free text stays outsupport_notes carries whatever was typed. A column not in the view cannot leak through it.
Pseudonymised, not anonymisedSomebody holds the key, so it is still personal data and still needs the row policy.
On the bench3 leaks to 0, all one cell. The step that finishes the design without an over-block.