<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vinícius de Carvalho</title>
    <description>The latest articles on DEV Community by Vinícius de Carvalho (@vinicius_carvalho).</description>
    <link>https://dev.to/vinicius_carvalho</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4185511%2Fdd659808-b228-48bf-8c1d-477a04ec88b7.png</url>
      <title>DEV Community: Vinícius de Carvalho</title>
      <link>https://dev.to/vinicius_carvalho</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vinicius_carvalho"/>
    <language>en</language>
    <item>
      <title>I tagged PII only in bronze and Unity Catalog masked it all the way to gold</title>
      <dc:creator>Vinícius de Carvalho</dc:creator>
      <pubDate>Thu, 12 Nov 2026 13:49:35 +0000</pubDate>
      <link>https://dev.to/indiciumai/i-tagged-pii-only-in-bronze-and-unity-catalog-masked-it-all-the-way-to-gold-2oo0</link>
      <guid>https://dev.to/indiciumai/i-tagged-pii-only-in-bronze-and-unity-catalog-masked-it-all-the-way-to-gold-2oo0</guid>
      <description>&lt;p&gt;Most breaches don't start where the protection is. An analyst copies customer data out of the warehouse into a shared folder or a spreadsheet for a quick analysis, and the copy outlives the reason it was made. Anyone who can open that folder now has the data, whether or not they ever had warehouse access. IBM found &lt;a href="https://www.ibm.com/think/insights/hidden-risk-shadow-data-ai-higher-costs/" rel="noopener noreferrer"&gt;shadow data&lt;/a&gt;, copies nobody tracks, in more than a third of breaches in 2024. The same gap exists inside the warehouse. Teams mask or tokenize the columns they flagged as sensitive, and the masks work, but each mask is attached to a column in one table. A table built from that column is a new table, and it starts with no protection. Protection stays where it was applied; it doesn't travel with the data.&lt;/p&gt;

&lt;p&gt;Health data makes the gap hardest to ignore. It is a special category under both the GDPR and Brazil's LGPD, and it is also the most expensive data to lose. IBM's &lt;a href="https://www.csoonline.com/article/567697/what-is-the-cost-of-a-data-breach-3.html" rel="noopener noreferrer"&gt;Cost of a Data Breach Report 2026&lt;/a&gt;, which covers breaches from March 2025 to February 2026, puts healthcare first at $6.64M per breach, with financial services close behind at $6.29M:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwf6iddkf6weaeoutv82.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwf6iddkf6weaeoutv82.png" alt="Bar chart of the average cost of a data breach by industry, March 2025 to February 2026: healthcare $6.64M, financial $6.29M, industrial and technology $5.50M each, down to transportation at $4.50M" width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In a lakehouse, that copy is usually a derived table: someone builds a table with &lt;code&gt;full_name&lt;/code&gt; and &lt;code&gt;ssn_hash&lt;/code&gt; from columns you classified, and the classification stays behind. So I tested whether Unity Catalog can make the protection follow the data. I tagged 10 columns in bronze, touched nothing downstream, and checked silver and gold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result: 33 of 33 expected tags arrived on the derived columns within about 4 seconds, and the masks applied to them. Not a single &lt;code&gt;SET TAGS&lt;/code&gt; outside bronze.&lt;/strong&gt; The way there also showed one trap that destroys data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature: tag propagation in Databricks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tag propagation&lt;/strong&gt; makes governed tags follow column lineage, from a source column to every column derived from it. Combined with &lt;strong&gt;ABAC policies&lt;/strong&gt;, column masks defined once on the catalog for any column carrying a tag, a derived table is masked the moment it exists.&lt;/p&gt;

&lt;p&gt;Before it works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is in Beta. A workspace admin enables it on the &lt;strong&gt;Previews&lt;/strong&gt; page.&lt;/li&gt;
&lt;li&gt;It is switched on &lt;strong&gt;per governed tag&lt;/strong&gt;: Catalog → Govern → Governed Tags → Configure → Tag propagation.&lt;/li&gt;
&lt;li&gt;You need &lt;code&gt;MANAGE&lt;/code&gt; on the tag, and the writes must run on &lt;strong&gt;serverless&lt;/strong&gt; compute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuj3d1pjz3lonqpq6plby.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuj3d1pjz3lonqpq6plby.png" alt="The governed tag page for sap.PersonalData.isPotentiallyPersonal, with tag propagation enabled and true as the default propagation value" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Details in the &lt;a href="https://docs.databricks.com/aws/en/admin/governed-tags/tag-propagation" rel="noopener noreferrer"&gt;tag propagation docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Setup&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; &lt;a href="https://synthetichealth.github.io/synthea/" rel="noopener noreferrer"&gt;Synthea&lt;/a&gt; synthetic health records (patients, encounters, providers), in a bronze → silver → gold medallion. None of it is real, so mistakes are cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform:&lt;/strong&gt; Databricks Free Edition, serverless SQL warehouse. The SQL ran both ad hoc and as Databricks Asset Bundle jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classification:&lt;/strong&gt; two Databricks-provided governed tags that map to LGPD's two categories, &lt;code&gt;sap.PersonalData.isPotentiallyPersonal&lt;/code&gt; (art. 5, I) and &lt;code&gt;sap.PersonalData.isPotentiallySensitive&lt;/code&gt; (art. 5, II). Ten columns tagged in bronze, in one file. &lt;code&gt;PASSPORT&lt;/code&gt; carries both tags on purpose. &lt;code&gt;CITY&lt;/code&gt;, &lt;code&gt;GENDER&lt;/code&gt; and &lt;code&gt;HEALTHCARE_EXPENSES&lt;/code&gt; stay untagged as negative controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write paths:&lt;/strong&gt; five silver objects, one per operation: CTAS with derived columns (&lt;code&gt;concat_ws&lt;/code&gt;, &lt;code&gt;right()&lt;/code&gt;, &lt;code&gt;sha2()&lt;/code&gt;), CTAS with a JOIN, &lt;code&gt;INSERT ... SELECT&lt;/code&gt; into a pre-existing table, &lt;code&gt;MERGE&lt;/code&gt;, and a view. Then three gold tables on top of silver, including a &lt;code&gt;GROUP BY&lt;/code&gt; on a sensitive column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Masks:&lt;/strong&gt; four catalog-level ABAC policies: redact (&lt;code&gt;***&lt;/code&gt;), pseudonymize (SHA-256), remove (&lt;code&gt;NULL&lt;/code&gt;), and one for columns with both tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personas:&lt;/strong&gt; four account groups, combined into five personas, from no access to the pipeline identity that writes everything in clear text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tagging is one statement per column, in bronze only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;synthea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bronze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patients&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="no"&gt;SSN&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="no"&gt;TAGS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sap.PersonalData.isPotentiallyPersonal'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A policy matches tags, not tables, which is what lets it reach silver and gold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="no"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;pii_personal_redact&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="no"&gt;CATALOG&lt;/span&gt; &lt;span class="n"&gt;synthea&lt;/span&gt;
&lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="no"&gt;MASK&lt;/span&gt; &lt;span class="n"&gt;synthea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;governance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mask_redact&lt;/span&gt;
&lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="sb"&gt;`account users`&lt;/span&gt;
&lt;span class="k"&gt;EXCEPT&lt;/span&gt; &lt;span class="sb"&gt;`synthea_pipeline`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sb"&gt;`synthea_pii_readers`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sb"&gt;`synthea_analysts`&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="no"&gt;TABLES&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="no"&gt;COLUMNS&lt;/span&gt; &lt;span class="nf"&gt;has_tag_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sap.PersonalData.isPotentiallyPersonal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="nf"&gt;has_tag_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sap.PersonalData.isPotentiallySensitive'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A user must never resolve to two different masks on the same column, or the query fails. The four policies cover three disjoint sets of tags. Two of them, redact and pseudonymize, share a set and split it by principal: redact skips &lt;code&gt;synthea_analysts&lt;/code&gt;, pseudonymize applies only to &lt;code&gt;synthea_analysts&lt;/code&gt;, and &lt;code&gt;synthea_pii_readers&lt;/code&gt; is exempt from both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measurement&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Propagation test.&lt;/strong&gt; The 33 column/tag pairs I expect in silver and gold live in a table. A &lt;code&gt;FULL OUTER JOIN&lt;/code&gt; against &lt;code&gt;information_schema.column_tags&lt;/code&gt; labels each one &lt;code&gt;OK&lt;/code&gt;, &lt;code&gt;MISSING&lt;/code&gt; or &lt;code&gt;EXTRA&lt;/code&gt;, and any &lt;code&gt;MISSING&lt;/code&gt; calls &lt;code&gt;raise_error()&lt;/code&gt;, which fails the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity check.&lt;/strong&gt; Distinct values against row count on hashed columns after every build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persona matrix.&lt;/strong&gt; One query per persona reads a value from each kind of column and classifies it as clear, hash, &lt;code&gt;***&lt;/code&gt; or &lt;code&gt;NULL&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tags on silver and gold&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;33/33 OK, 0 EXTRA&lt;/strong&gt;, no &lt;code&gt;SET TAGS&lt;/code&gt; outside bronze&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write paths covered&lt;/td&gt;
&lt;td&gt;CTAS, JOIN, &lt;code&gt;INSERT ... SELECT&lt;/code&gt;, &lt;code&gt;MERGE&lt;/code&gt;, view, and the second hop into gold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to propagate&lt;/td&gt;
&lt;td&gt;within about &lt;strong&gt;4 seconds&lt;/strong&gt; of the last write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle jobs on serverless&lt;/td&gt;
&lt;td&gt;4 runs, 33/33 each, 30 to 50 seconds per run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rebuild with &lt;code&gt;CREATE OR REPLACE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;all 33 tags kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Negative controls&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;city&lt;/code&gt;, &lt;code&gt;gender&lt;/code&gt;, &lt;code&gt;healthcare_expenses&lt;/code&gt; stayed untagged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What each persona saw, set by group membership:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Groups&lt;/th&gt;
&lt;th&gt;Personal (SSN, name)&lt;/th&gt;
&lt;th&gt;Sensitive (race)&lt;/th&gt;
&lt;th&gt;Passport (both tags)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;&lt;code&gt;***&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NULL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;analysts&lt;/td&gt;
&lt;td&gt;SHA-256 hash&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NULL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;analysts + pii_readers&lt;/td&gt;
&lt;td&gt;clear&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NULL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;analysts + pii_readers + sensitive_readers&lt;/td&gt;
&lt;td&gt;clear&lt;/td&gt;
&lt;td&gt;clear&lt;/td&gt;
&lt;td&gt;clear&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pipeline&lt;/td&gt;
&lt;td&gt;clear&lt;/td&gt;
&lt;td&gt;clear&lt;/td&gt;
&lt;td&gt;clear&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I repeated the matrix after &lt;a href="https://docs.databricks.com/aws/en/security/auth/rbac/switch-roles" rel="noopener noreferrer"&gt;switching to each group as a role&lt;/a&gt;, which makes Databricks run queries with that group's permissions only, and every result matched its policies. Analysts got hashes, &lt;code&gt;pii_readers&lt;/code&gt; got personal columns in clear, &lt;code&gt;sensitive_readers&lt;/code&gt; got &lt;code&gt;race&lt;/code&gt; in clear and personal columns as &lt;code&gt;***&lt;/code&gt;, and the pipeline got everything. No single reader role saw &lt;code&gt;PASSPORT&lt;/code&gt;, because that takes both groups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Propagation is conservative.&lt;/strong&gt; &lt;code&gt;full_name&lt;/code&gt;, &lt;code&gt;ssn_last4&lt;/code&gt; and &lt;code&gt;ssn_hash&lt;/code&gt; all inherit the Personal tag, so a hash computed in the pipeline gets masked like the raw value. That is the right call: an unsalted SHA-256 of a 9-digit SSN can be reversed by hashing every possible value, which is why the &lt;a href="https://www.edpb.europa.eu/system/files/2025-01/edpb_guidelines_202501_pseudonymisation_en.pdf" rel="noopener noreferrer"&gt;EDPB&lt;/a&gt; treats such hashes as personal data. In gold, &lt;code&gt;race&lt;/code&gt; is a &lt;code&gt;GROUP BY&lt;/code&gt; dimension in an aggregate report, and it inherits Sensitive: without access, the report still has its 18 rows, but &lt;code&gt;race&lt;/code&gt; and &lt;code&gt;ethnicity&lt;/code&gt; read &lt;code&gt;NULL&lt;/code&gt; in every one, so only &lt;code&gt;gender&lt;/code&gt; is left to tell them apart. For analysts, the pseudonymized SSN was identical in bronze and silver, and a bronze-to-silver join on it returned all 1,163 rows.&lt;/p&gt;

&lt;p&gt;To release a derived column, change the policy (pseudonymize instead of redact). Do not remove the tag downstream, because classification is supposed to live in one place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hpayij0yrzky3gq430q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hpayij0yrzky3gq430q.png" alt="Tag propagation visual Example" width="800" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Problems I hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A masked writer persists the mask.&lt;/strong&gt; This one cost data. A CTAS reads the source through the masks of whoever runs it. On the first silver/gold build, &lt;code&gt;ssn_hash&lt;/code&gt; had &lt;strong&gt;1 distinct value across 1,163 rows&lt;/strong&gt;, the hash of &lt;code&gt;***&lt;/code&gt;, and &lt;code&gt;gold.demographics&lt;/code&gt; had a single race, &lt;code&gt;***&lt;/code&gt;. No policy was involved anymore. The data itself was gone.&lt;/p&gt;

&lt;p&gt;Later I repeated it on purpose, after switching to the analyst group as a role. The CTAS stored SSN and names as SHA-256 and &lt;code&gt;race&lt;/code&gt; and &lt;code&gt;passport&lt;/code&gt; as &lt;code&gt;NULL&lt;/code&gt;, in all 1,163 rows, and an admin reading that copy gets the same hashes and nulls. The tags still propagated, so the copy stays governed, but the clear values are gone for everyone.&lt;/p&gt;

&lt;p&gt;So anyone without access who runs a CTAS cuts the new table off from the real values: its columns hold &lt;code&gt;***&lt;/code&gt;, &lt;code&gt;NULL&lt;/code&gt; or a SHA-256 string, never the original. The hash is the one case where the value can come back, by the same rule as &lt;code&gt;ssn_hash&lt;/code&gt; above. The pseudonymization mask does add a salt, but the salt is in the public repo, so hashing every possible SSN with it still reverses the copy. In production the salt belongs in a secret.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; a &lt;code&gt;synthea_pipeline&lt;/code&gt; group in the &lt;code&gt;EXCEPT&lt;/code&gt; of every mask, and the pipeline running as a member of it. A job run as a pipeline-only user produced 1,163 distinct hashes, 6 races and 887 passports, with no &lt;code&gt;***&lt;/code&gt; written. A bundle job's &lt;code&gt;run_as&lt;/code&gt; takes a user or service principal, never a group, so membership is what makes the pipeline write clean data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;ssn_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;distinct_hashes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="cm"&gt;(*) AS rows
FROM synthea.silver.patients;   -- expect 1,163 / 1,163, not 1 / 1,163
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;EXCEPT&lt;/code&gt; means OR, not AND.&lt;/strong&gt; &lt;code&gt;PASSPORT&lt;/code&gt; carries both tags, so only users in &lt;em&gt;both&lt;/em&gt; reader groups should see it. The obvious policy says &lt;code&gt;EXCEPT synthea_pii_readers, synthea_sensitive_readers&lt;/code&gt;, but &lt;code&gt;EXCEPT&lt;/code&gt; exempts anyone in &lt;em&gt;any&lt;/em&gt; listed group, so a user in &lt;code&gt;sensitive_readers&lt;/code&gt; alone would skip the mask and see passport numbers. &lt;code&gt;EXCEPT&lt;/code&gt; cannot say "in both groups", so the policy exempts only the pipeline, and the mask function checks both groups itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="nf"&gt;is_account_group_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'synthea_pii_readers'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nf"&gt;is_account_group_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'synthea_sensitive_readers'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I caught this by reading the policy definitions, not through a test. The role test above confirms the fix: as &lt;code&gt;sensitive_readers&lt;/code&gt; alone, &lt;code&gt;PASSPORT&lt;/code&gt; reads &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Group membership changes are not instant.&lt;/strong&gt; After I left the reader groups in the UI, &lt;code&gt;is_account_group_member()&lt;/code&gt; kept returning true for about 2 minutes. A persona test in that window proves nothing, so I check effective membership in the same session before each run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Smaller ones.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The governance job once recreated the policies while the medallion job was mid-build. Run governance first, or use one job with a dependency.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DROP POLICY&lt;/code&gt; has no &lt;code&gt;IF EXISTS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Free Edition has no account API (&lt;code&gt;databricks account groups list&lt;/code&gt; fails even for a workspace admin), so the groups were created in the UI. Its workspace also lacks &lt;a href="https://docs.databricks.com/aws/en/data-governance/unity-catalog/data-classification" rel="noopener noreferrer"&gt;Data Classification&lt;/a&gt;, the LLM-assisted scanner that would replace hand-tagging at scale.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I did not test
&lt;/h2&gt;

&lt;p&gt;These come from the docs, not from the lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Declarative pipelines (DLT) and pipeline materialized views do not propagate tags.&lt;/li&gt;
&lt;li&gt;Until a tag arrives on a derived table, policies that reference it are not enforced there.&lt;/li&gt;
&lt;li&gt;Two policies that resolve to different masks for the same user and column make the query fail.&lt;/li&gt;
&lt;li&gt;A STRING mask such as &lt;code&gt;'***'&lt;/code&gt; on a &lt;code&gt;DATE&lt;/code&gt; or &lt;code&gt;INT&lt;/code&gt; column breaks reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing under every role.&lt;/strong&gt; Every role was tested for reads, but I ran a CTAS under one role only, the analysts, in Problem 1.&lt;/li&gt;
&lt;li&gt;Whether the &lt;code&gt;class.*&lt;/code&gt; tags from Data Classification propagate like custom governed tags.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Turn on propagation for each governed tag first. Nothing else works without it.&lt;/li&gt;
&lt;li&gt;Keep every &lt;code&gt;SET TAGS&lt;/code&gt; in one file for the source layer, and make a missing downstream tag a failing test.&lt;/li&gt;
&lt;li&gt;Exempt the pipeline identity from every mask, and check distinct counts after each build. Masked users get &lt;code&gt;CREATE&lt;/code&gt; only in a sandbox schema.&lt;/li&gt;
&lt;li&gt;Make sure no user and column can resolve to two masks: disjoint tags or disjoint principals.&lt;/li&gt;
&lt;li&gt;Expect derived columns, hashes and aggregate dimensions to stay sensitive. Loosen them with a policy, not by removing tags.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tag propagation is still in Beta, but it is one of the most useful governance features I have tested in Unity Catalog. It moves classification out of a spreadsheet and into the lineage, so "is this derived table protected?" gets the same answer as "is its source protected?", without anyone having to remember to ask. That matters more every year. The same IBM report found that one in four malicious breaches were AI-enabled, and phishing was the most common way in. An attack that goes through people ends with a valid login, and what that login can read is decided by access policies. When every copy inherits its policy, a phished analyst account gets hashes and &lt;code&gt;NULL&lt;/code&gt;s, not the one table somebody forgot to tag.&lt;/p&gt;

&lt;p&gt;The full lab, with the bundle, the SQL and the tests, is on GitHub: &lt;a href="https://github.com/ViniCarvalhoData/synthea_databricks" rel="noopener noreferrer"&gt;ViniCarvalhoData/synthea_databricks&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>governance</category>
      <category>privacy</category>
      <category>dataengineering</category>
    </item>
  </channel>
</rss>
