Crosshire
← Handbook
GovernGoverned tags · policies · masks

16Access & ABAC

This route decides how a column stops being readable: by tag, once, at schema level — not by a mask written per table by whoever remembered. ABAC went generally available in May 2026, which retires the preview-risk paragraph and the per-table fallback that every permissions concept written before then still carries. The work in week one is the tag vocabulary. The policies are ten lines each and follow from it.

Stand 29.07.2026

The tag-driven model

Three objects, in a fixed chain: a governed tag says what a column is, a policy says who is affected and where, and a UDF does the actual work. Getting the chain straight is most of the route.

#ABAC is GA, and that changes the design

What
Unity Catalog ABAC — row filter and column mask policies — plus governed tags and automated data classification are generally available. They were Public Preview when most DACH permissions concepts on the shelf were written, which is why those documents contain a risk paragraph and a per-table fallback. Both can go.
The model is three objects in one direction of travel. The policy is not the logic; the logic is a UDF you can call, test and version like any other function.
text
tag → policy → UDF
GOVERNED TAG    pii = email | phone | address        account level, values fixed
      |
      |  SET TAG ON COLUMN dwh_prod.gold.dim_customer.email pii = email
      v
COLUMN TAG      one column, one governed value
      |
      |  MATCH COLUMNS has_tag('pii') AS pii_col
      v
POLICY          ON SCHEMA dwh_prod.gold   TO ... EXCEPT ...   evaluated per query
      |
      |  COLUMN MASK dwh_prod.gold.mask_pii
      v
UDF             mask_pii(value STRING) RETURNS STRING
A policy attaches at catalog, schema or table and applies downward. It is evaluated dynamically against the tags as they are at query time — so moving a table into dwh_prod.gold puts it under the schema's policies with nobody redeploying anything.
Why here
The alternative is per-table: ALTER TABLE … ALTER COLUMN … SET MASK on each table as it is built. Its failure mode is arithmetic — N tables times M sensitive columns, each needing one person to remember at the moment of creation. The table that gets missed is the one built in a hurry against a deadline, which is also the one most likely to be handed to someone outside the team.
Tag-driven inverts the default. A new table is protected the moment it is tagged, not the moment someone remembers to write a mask. The policy written today for gold.dim_customer covers the table someone builds next quarter, on its first query, unedited. The reviewer's job shrinks from "is there a mask on this table" to "are the columns tagged" — one query against information_schema.column_tags.
Doing it
  • Delete the preview caveat and the per-table fallback from any permissions concept written before May 2026 — leaving it in makes the whole document look unverified.
  • Fix the tag vocabulary before writing any policy; the policy is trivial once the vocabulary exists, and unfixable once tables are tagged three ways.
  • Attach policies at schema level (dwh_prod.gold). Table-level policies are for genuine exceptions and should be countable on one hand.
  • Check compute before promising anything: ABAC needs serverless, or DBR 16.4+ standard, or dedicated 16.4+ with fine-grained access control enabled.
Embedding it
  • Have the team tag one column and watch the mask appear on a table nobody edited. The propagation is the whole argument and it lands in thirty seconds.
  • Ask in every review of a new gold table: 'which of these columns carries a governed tag, and who checked?' — never 'did you add a mask'.
  • Give the tag vocabulary an owner on the client side, by name. A vocabulary owned by the consultant stops being maintained at handover.
  • When someone proposes a table-level policy, make them state why the schema-level one is wrong. The answer usually reveals a tagging gap, and the tagging gap is the real bug.
Defaults
  • Policies at schema level; table-level only as a written exception.
  • One UDF per class of data, not per column — only one distinct mask resolves per table and user anyway.
  • Tags and policies deployed as DDL in the bundle, exercised in dwh_dev and dwh_test first.
Gotchas
  • Quoting the preview status. It moved in May 2026, and a client who checks the docs while you are talking discounts everything else you said. Cite the date.
  • Assuming a policy protects a view. You cannot attach an ABAC policy to a view; policies on the underlying tables evaluate with the session user's identity when the view is queried. A view built to hide columns and a policy are different mechanisms, and only one survives someone querying the base table.
  • Jobs on Databricks Runtime 15.4 LTS and below do not get masked data — they fail outright on a protected table. The reflex fix is to add the job's service principal to EXCEPT, which does not downgrade its access; it removes protection entirely for everything that job writes.
  • Tag and policy changes take a few minutes to take effect. Engineers tag a column, re-run the query, see raw values, conclude the policy is broken, and start editing a policy that was correct.

#Governed tags are the vocabulary

What
An ungoverned tag is a key-value pair anyone with APPLY TAG can invent. A governed tag is declared once at account level with a fixed list of allowed values, and needs the ASSIGN permission to apply. ABAC policies read governed tags only, which is the point: a policy that matched free-text tags would be a policy defeated by a typo.
sql
Account level, once — the whole vocabulary for this warehouse
CREATE GOVERNED TAG pii
  DESCRIPTION 'Kind of personal data this column carries'
  VALUES ('email', 'phone', 'address');

CREATE GOVERNED TAG row_filter
  DESCRIPTION 'Row-level access scheme this table is subject to'
  VALUES ('territory');

CREATE GOVERNED TAG row_scope
  DESCRIPTION 'Column a row-filter policy scopes on'
  VALUES ('country');
Tags are then applied to catalogs, schemas, tables, views, volumes, functions and columns with SET TAG, in DDL, from the bundle — the same statements running against dwh_dev, dwh_test and dwh_prod.
sql
Applying them — the only three PII columns in the canonical model
SET TAG ON COLUMN dwh_prod.gold.dim_customer.email   pii = email;
SET TAG ON COLUMN dwh_prod.gold.dim_customer.phone   pii = phone;
SET TAG ON COLUMN dwh_prod.gold.dim_customer.street  pii = address;

-- the row-filter pair: which table is filtered, and on which column
SET TAG ON TABLE  dwh_prod.gold.dim_customer          row_filter = territory;
SET TAG ON COLUMN dwh_prod.gold.dim_customer.country  row_scope  = country;

-- and the thing you will actually run in a review
SELECT table_name, column_name, tag_name, tag_value
FROM   dwh_prod.information_schema.column_tags
WHERE  schema_name = 'gold'
ORDER  BY table_name, column_name;
Why here
Tag values are case-sensitive, and a policy matching has_tag_value('pii', 'email') does not match a column tagged PII = e_mail. Ungoverned, that divergence appears the first week two people tag columns independently, and it is invisible: the column looks tagged, the policy looks attached, and the data is readable. Governed values make the wrong tag impossible to apply rather than hard to find.
The vocabulary is also the durable deliverable. Policies get rewritten; a tag taxonomy that maps onto how the business talks about its data outlives the engagement and is what a new engineer reads to learn what is sensitive here.
Doing it
  • Declare the governed tags before the first table is tagged. A value rename later means re-tagging every column that carries it.
  • Grant ASSIGN on each governed tag to the group that owns the domain, not to everyone with APPLY TAG.
  • Put every SET TAG statement in the bundle beside the DDL of the table it describes, so a new column and its tag arrive in the same pull request.
  • Keep the vocabulary small enough to recite. Three PII values and two access tags cover this warehouse — one value per personal column in the model, and no value that nothing carries. A declared value with no column behind it reads as an oversight in the next review.
Embedding it
  • Run the vocabulary session with the data owners in the room, not the engineers — they know whether a delivery address is personal data here.
  • Have the team write the information_schema.column_tags review query themselves and put it in CI. A vocabulary with no drift check is a wish.
  • Ask someone to tag a column in the Catalog Explorer UI, then ask how it reaches dwh_prod. The gap answers itself and nobody clicks tags again.
Defaults
  • Governed tags for anything a policy reads; ungoverned tags are fine for cost and ownership metadata.
  • SET TAG in DDL, in the bundle, never clicked in the UI — a clicked tag exists in one environment.
  • Tag keys and values lower-case ASCII, matching the identifier rule in the architecture route.
Gotchas
  • Tags do not inherit down to the column level. Tagging gold.dim_customer with pii does nothing for a policy matching has_tag('pii') on columns — the columns are untagged and the data is served raw. This is the single most common 'the policy does not work' report, and the table tag makes it look like the job was done.
  • Tagging in Catalog Explorer. It applies to one environment. The policy is then tested successfully in dev and silently protects nothing in prod, because the columns there were never tagged.
  • Case sensitivity in both key and value. pii and PII are two different tags, and only one of them is the one your policy matches.
  • Renaming a governed tag value after tables are tagged. There is no cascade: the old value stays on the columns, the policy stops matching them, and nothing errors.

Policies on dim_customer

Two policies cover this warehouse: one masks the personal columns for everyone who is not on a short list, one restricts rows to a sales territory. Both are attached to the gold schema and neither names a table.

#One column mask for the whole PII class

What
The mask is a UDF whose return type matches the column type. All three personal columns on gold.dim_customer are STRING, so one function covers them.
sql
The UDF, then the policy — attached to the schema, naming no table
CREATE OR REPLACE FUNCTION dwh_prod.gold.mask_pii(value STRING)
RETURNS STRING
COMMENT 'Redact personal data. NULL stays NULL so counts do not move.'
RETURN CASE WHEN value IS NULL THEN NULL ELSE '***' END;

CREATE OR REPLACE POLICY mask_customer_pii
ON SCHEMA dwh_prod.gold
COMMENT 'Redact every column tagged pii for all but the named readers'
COLUMN MASK dwh_prod.gold.mask_pii
TO `account users`
EXCEPT `dwh_pii_readers`, `dwh_prod_pipelines`
FOR TABLES
MATCH COLUMNS has_tag('pii') AS pii_col
ON COLUMN pii_col;
MATCH COLUMNS binds the alias pii_col to every column satisfying the condition, and ON COLUMN pii_col applies the mask to each of them. Three columns, one policy, and the next tagged column joins without an edit.
sql
What a non-exempt analyst sees, and how you check the policy is attached
SELECT customer_id, customer_name, email, city, country
FROM   dwh_prod.gold.dim_customer
WHERE  _tech_is_current;

-- customer_id | customer_name    | email | city      | country
-- C-100482    | Nordbau Handel   | ***   | Hannover  | DE
-- C-100483    | Alpin Technik    | ***   | Innsbruck | AT

SHOW EFFECTIVE POLICIES ON TABLE dwh_prod.gold.dim_customer;
-- policy_name         | policy_type  | function                    | principals
Why here
The platform resolves one distinct column mask per column and user — the scope is the column, not the table. Two mask policies on the same table are fine while they target different columns; the query fails only when two distinct masks resolve for the same column and the same principal, with COLUMN_MASKS_FEATURE_NOT_SUPPORTED.MULTIPLE_MASKS. (Row filters are the stricter case: one distinct filter per table and user.)
That still argues for one generic function per sensitive class rather than a proliferation of masks — but for a design reason, not because the platform forbids the alternative. A tag-driven policy naturally produces exactly one mask per column, so you only meet this limit by writing a second policy that overlaps the first, which is a signal the two policies should have been one.
It also keeps the exempt list short. Two groups appear in EXCEPT, both named in the permissions concept. In the per-table world, the answer to 'who can read customer email' is a sweep of every mask on every table.
Doing it
  • Write mask_pii once, in gold, and grant EXECUTE on it to the same principals the policy names.
  • Keep NULL returning NULL. A mask that turns NULL into '***' changes COUNT(email) and someone will reconcile against it.
  • Deploy the policy to dwh_dev and dwh_test with the same statement — a policy that exists only in prod is a policy first exercised in prod.
  • Verify with SHOW EFFECTIVE POLICIES ON TABLE against a table you did not name in the policy. That is the proof the schema attachment works.
Embedding it
  • Have an engineer query dim_customer as themselves and as a member of dwh_pii_readers, side by side. Two result sets persuade better than any diagram, and it is the screenshot for the concept document.
  • Give the team the second policy to write — the one for a class you have not covered — and review it rather than authoring it.
  • Let them hit the one-mask-per-table conflict once, in dev, by writing a second policy. The error teaches the constraint better than the docs page.
Defaults
  • One mask UDF per data class; differentiate with tags, not with functions.
  • Return type matches column type exactly — a STRING mask on a DECIMAL column is rejected, not coerced.
  • EXCEPT lists contain groups only, and every group is justified in writing.
Gotchas
  • A second column-mask policy on the same table for the same principal. Only one distinct mask can resolve, so the query errors instead of masking — and it errors for the analyst, at 08:00, on a dashboard, not for the person who added the policy.
  • Masking as a substitute for deletion. The value is still stored, still in the Delta history, and still leaves the platform in any export by an exempted identity. Under GDPR the column remains personal data — see the classification route.
  • TO `account users` includes service principals. Every job that is not explicitly exempted starts reading '***', usually discovered when a downstream table quietly fills with asterisks that then survive into a report.
  • Forgetting EXECUTE on the UDF. Creating a policy needs MANAGE on the securable and EXECUTE on the masking function, so the statement is rejected outright — and the error names the function rather than the policy, which reads as if the function were never deployed. The bundle order is function, then grant, then policy; get it wrong and the deploy fails on a green-looking pull request.
  • CREATE OR REPLACE POLICY replaces the whole definition, not the clauses you retyped. There is no partial edit: ship a change to TO without re-stating EXCEPT and the exemptions are gone, the pipelines start writing '***' on the next run, and the pull request looks like a two-line change. Keep the policy DDL in one file and always edit it whole.

#Territory row filter

What
This distributor sells into DE, AT, CH and NL, and sales roles should see their own territory. The filter function takes the scoping column and returns a boolean; group membership does the deciding.
sql
One function, one policy, four territory groups
CREATE OR REPLACE FUNCTION dwh_prod.gold.in_sales_territory(country STRING)
RETURNS BOOLEAN
COMMENT 'True if the caller belongs to the territory group for this country'
RETURN is_account_group_member(concat('dwh_territory_', lower(country)));

CREATE OR REPLACE POLICY territory_rows
ON SCHEMA dwh_prod.gold
COMMENT 'Sales roles see only the countries their territory group covers'
ROW FILTER dwh_prod.gold.in_sales_territory
TO `dwh_sales`
EXCEPT `dwh_prod_pipelines`
FOR TABLES
WHEN has_tag_value('row_filter', 'territory')
MATCH COLUMNS has_tag_value('row_scope', 'country') AS country_col
USING COLUMNS (country_col);
WHEN selects which tables the policy touches, from their tags. MATCH COLUMNS finds the scoping column, and USING COLUMNS passes it as the function's argument. Tag a second table row_filter = territory with a column tagged row_scope = country and it is filtered the same way, immediately.
The EXCEPT dwh_prod_pipelines line looks redundant beside TO dwh_sales — a pipeline is not a salesperson — and it is not. Service principals get added to business groups for an unrelated access request and nobody rereads the policy; a territory-scoped pipeline then writes a partial table, and no test tells a partial load apart from a quiet day.
Why here
The default alternative is a view per territory — dim_customer_de, dim_customer_at — and it multiplies: four countries times every table anyone wants scoped, each view with its own grants, each drifting from the base table's schema the first time a column is added.
The row filter is evaluated at query time against group membership, so adding a salesperson to dwh_territory_ch is the entire onboarding step. No view, no grant, no deployment.
Doing it
  • Derive the group names from the data: country values are DE, AT, CH and NL, so the groups are dwh_territory_de, _at, _ch, _nl and the mapping is a lower() call rather than a lookup table.
  • Keep the filter function to group membership and cheap expressions — it runs per row on every query against every covered table.
  • Tag the table and the scoping column together, in one statement pair, so a table can never be marked filtered without a column to filter on.
  • Test as a member of exactly one territory group, and as a member of none.
Embedding it
  • Have the team add a fifth territory end to end — group, tag, test — while you watch. That is the operation they repeat without you.
  • Ask what a user in no territory group sees, before showing them. The guess is 'an error'; the answer is zero rows.
  • Make them write the reconciliation test proving an unfiltered service principal and a DE user disagree by exactly the non-DE rows.
  • Hand over the decision about filtering facts rather than making it. It is a business question about what a territory manager may see, and it needs a name against it.
Defaults
  • One filter function, parameterised by the scoping column; territories are groups, not code.
  • Table tag plus column tag applied together — the pair is the contract.
  • Pipeline identities exempted, so builds are not silently territory-scoped.
  • The asymmetry between filtered dimensions and unfiltered facts written into the permissions concept.
Gotchas
  • A user in no territory group sees zero rows, with no error. Their dashboard shows €0 revenue and they report a data outage; you spend the morning in the pipeline logs. Give every account exactly one territory group at creation and assert it in the access review.
  • The function runs per row. A filter that joins to a mapping table instead of calling is_account_group_member turns every scan of every covered table into a join, and the cost surfaces as a warehouse that got slower for no attributable reason.
  • Only one distinct row filter resolves per table and user, exactly as with masks. A second policy with a different function on the same table fails the query rather than intersecting the conditions.
  • Tagging a table row_filter = territory when no column carries row_scope. Nothing errors and nothing is filtered — the table looks governed in the inventory while serving every row.
  • The unknown member disappears. dim_customer carries an UNKNOWN row so unresolvable facts stay visible instead of being dropped; its country resolves to a dwh_territory_ group that does not exist, and a NULL country makes concat itself NULL, so the row is filtered out for every sales user. The one row that exists to make bad data visible is the one row the filter hides. Decide who owns the unknown territory and coalesce it inside the function.

Limits and review

Two things separate a permissions concept that survives an audit from one that reads well: knowing precisely what an exemption grants, and being able to show the policies are still attached.

#What EXCEPT buys, and what it costs

What
EXCEPT is not a lower privilege level. It is a full bypass of the policy for that principal, on every covered table, for every covered column. There is no partial exemption.
The same operation, two identities
Operation on gold.dim_customerSubject to the policyNamed in EXCEPT
SELECT email'***'the address
VERSION AS OF 42 / TIMESTAMP AS OFquery fails — policies cannot be evaluated against a historical snapshotthe address, at that version
CREATE TABLE … DEEP CLONE / SHALLOW CLONEunsupported, failssucceeds — and the clone is a new securable
Delta Sharing as share ownershare creation blockedshares the unmasked column
Building a materialized view over the tablepipeline failsmaterialises unmasked values
The same logic applies to a clone. A policy attaches to a securable — a catalog, a schema, a table. Clone dwh_prod.gold.dim_customer into a schema the policy does not cover and the copy is unprotected, whatever the source was.
Why here
"The column is masked" and "they cannot get the data" are different statements, and permissions concepts routinely assert the first while meaning the second. That gap is where a data-protection review finds its finding: backup, clone, time travel and pipeline identities all need an exemption to function, every one of those exemptions is total, and the list of them is the real answer to who can read customer email.
Doing it
  • Enumerate every principal in every EXCEPT clause into one table in the permissions concept, with the operation that forced the exemption next to it.
  • Tag the outputs of exempted pipelines — the materialized view, the aggregate, the export table — not just the sources.
  • Decide where clones may land, and make the target schema covered by the same policies as the source.
  • Check whether anything depends on time travel — a restore runbook, a reconciliation query — before the policy lands, because it will stop working.
Embedding it
  • Have the team try a time-travel query against a protected table without an exemption. The failure is the lesson; explaining it is not.
  • Ask them to name every exempted principal from memory in month three. If they cannot, the list is too long and the control is theatre.
  • Make removing an exemption a normal ticket, and do one together, so the list can shrink and not only grow.
  • Get the data-protection officer to read the exemption table rather than the policy DDL. It is the artefact that answers their question.
Defaults
  • One documented exemption table, reviewed quarterly, with an owner per row.
  • Exempt service principals, never people, for machine operations.
  • Outputs of exempted pipelines carry the same governed tags as their inputs.
  • Clone and export targets live inside the policy's scope, or the export does not happen.
Gotchas
  • Adding a service principal to EXCEPT to fix a failing job. It fixes the job and removes protection from everything that job touches, and it looks like a one-word edit in a pull request.
  • Time travel silently leaving the toolbox. Nobody notices until an incident, when the query that would have shown yesterday's state fails on a table that is now protected.
  • A materialized view or aggregate built from tagged sources but never tagged itself. The mask holds on dim_customer and the same email sits unmasked one table downstream — which is where BI reads.
  • Treating masking as anonymisation. It is neither; see the classification route, where the difference decides whether the data is in scope at all.

#Group grants, and proving they are still right

What
Grants go to groups. The policy quota enforces it anyway — twenty principals per policy across TO and EXCEPT combined — but the reason is the one from the Unity Catalog route: an individual grant outlives the individual.
sql
The complete grant set for an analyst group
GRANT USE CATALOG ON CATALOG dwh_prod            TO `dwh_analysts`;
GRANT USE SCHEMA  ON SCHEMA  dwh_prod.gold       TO `dwh_analysts`;
GRANT SELECT      ON SCHEMA  dwh_prod.gold       TO `dwh_analysts`;
GRANT EXECUTE     ON FUNCTION dwh_prod.gold.mask_pii TO `dwh_analysts`;

-- gold only. No grant on silver, no grant on bronze.
-- Territory scoping is membership of dwh_territory_*, not a grant.
Reviewing it has one awkward edge: there is no information schema view for ABAC policies. The inventory is SHOW EFFECTIVE POLICIES ON TABLE looped over the table list, or the Unity Catalog REST API. The headline count is something you assemble, not something you SELECT.
sql
The three review queries
-- 1. drive the inventory loop; run SHOW per row, concatenate client-side.
--    Materialized views and streaming tables carry policies too, so filtering to
--    MANAGED alone drops exactly the downstream copies the exemptions leak into.
SELECT table_catalog, table_schema, table_name, table_type
FROM   dwh_prod.information_schema.tables
WHERE  table_schema = 'gold'
  AND  table_type IN ('MANAGED', 'MATERIALIZED_VIEW', 'STREAMING_TABLE');

-- 2. candidate personal columns carrying no pii tag. Zero rows is a pass.
--    The list is the vocabulary, not dim_customer's column list: it fires when
--    any gold table grows an email or a street. customer_name is deliberately
--    absent -- in this B2B model it is the company name, not personal data.
SELECT c.table_name, c.column_name
FROM   dwh_prod.information_schema.columns c
LEFT   JOIN dwh_prod.information_schema.column_tags t
       ON  t.schema_name = c.table_schema
       AND t.table_name  = c.table_name
       AND t.column_name = c.column_name
       AND t.tag_name    = 'pii'
WHERE  c.table_schema = 'gold'
  AND  c.column_name IN ('email', 'phone', 'street')
  AND  t.tag_name IS NULL;

-- 3. every policy change in the last 30 days, with the editor
SELECT event_time,
       user_identity.email          AS actor,
       action_name,
       request_params['name']       AS policy_name
FROM   system.access.audit
WHERE  service_name = 'unityCatalog'
  AND  action_name IN ('createPolicy', 'deletePolicy')
  AND  event_date >= current_date() - INTERVAL 30 DAYS
ORDER  BY event_time DESC;
Why here
A control is only a control if someone can demonstrate it is still in place. Query 2 earns its keep: it catches the column that arrived in a release and nobody tagged, which is how a tag-driven scheme decays — not policies being deleted, but data arriving outside the vocabulary.
Query 3 carries a trap worth knowing before an auditor finds it. There is no updatePolicy verb. An edit is CREATE OR REPLACE POLICY, which writes a second createPolicy row against the same name — so a naive change count under-reports edits by exactly the rate at which the team edits policies, that being the only edit mechanism on offer. Group by policy name and treat repeats as edits.
Doing it
  • Grant on gold only. Analysts reading silver is a contract violation before it is a governance one.
  • Run the three queries on a schedule and put the output somewhere a reviewer finds it without asking an engineer.
  • Watch the quotas: 100 policies per catalog or schema, 50 per table, 20 principals per policy across TO and EXCEPT, and 3 column conditions per MATCH COLUMNS clause. They refuse the next policy rather than degrading, usually mid-rollout.
  • Treat the audit table itself as sensitive — it records who read what, and it needs its own access rules.
Embedding it
  • Hand the access review to the client's own reviewer and sit silently through the first one. The questions they cannot answer are your backlog.
  • Have the team schedule the untagged-column query as a job and route the failure to their rota, not to your inbox.
  • Ask them to explain the missing updatePolicy verb to the auditor themselves. Owning the awkward detail is what makes the rest credible.
  • Retire your own access at handover, in front of them, and let them run the review that proves it.
Defaults
  • Grants to groups only, on gold only, deployed as DDL.
  • Access review quarterly, with the exemption table, the untagged-column query and a captured policy inventory as its exhibits — the inventory is the only durable record, since the platform has no view to query later.
  • Alert on createPolicy and deletePolicy against dwh_prod, routed to a rota.
Gotchas
  • Counting createPolicy events as creations. Most of them are edits, because CREATE OR REPLACE is the only way to change a policy and it emits the same verb.
  • Expecting system.information_schema.effective_policies. It does not exist. Anyone who writes a review query against it gets a table-not-found and concludes ABAC is not enabled.
  • Hitting the 20-principal ceiling on a policy. It appears the day someone lists individual service principals in EXCEPT instead of one group, and the policy edit is rejected while the old version stays live.
  • Granting SELECT on the catalog rather than on gold. It reaches bronze, where the raw SAP extract sits with no mask, no tag and no policy — the whole scheme bypassed by one convenient wildcard.
  • Assuming the reviewer can assemble the inventory themselves. SHOW EFFECTIVE POLICIES and DESCRIBE POLICY require MANAGE on the securable or ownership of it — which a data-protection officer does not have and should not be given. Somebody with MANAGE captures the inventory on a schedule, or the quarterly review stalls waiting on an engineer's calendar.

#Governance Hub, and the loop you build before you get it

What
Governance Hub is an account-level console with three pages — Data, AI and Cost — reporting posture with prioritised insights and recommendations. The Data page is the one this route cares about: object access, asset usage, governed tags, data classification and data quality monitoring, across the estate. It introduces no new permissions; every view respects the Unity Catalog grants the viewer already has.
It is in Beta, and — unlike most preview features — an account admin switches it on themselves from the account console Previews page. Access is not gated by Databricks and there is nothing to request. So "we cannot get access yet" is not a reason to defer; the honest reasons to be careful are that it is Beta and that the data takes up to a day to appear after enabling.
The reflex it produces in most teams is still the wrong one: enable it, look at it, and call that governance. The Hub is a view over answers you can already compute. Build the loop against information_schema and system.* regardless — then the Hub is the reporting layer on a process that already exists, rather than a console nobody has attached to a decision.
sql
src/dwh/governance/posture.sql — the four numbers a review actually moves
-- One row per metric, so the output is a posture SNAPSHOT you can trend.
-- These are the same questions the Hub's Data page consolidates; computing
-- them yourself is what makes the Hub an upgrade rather than a migration.

-- 1. classification coverage: candidate personal columns carrying a pii tag.
SELECT 'pii_coverage_pct' AS metric,
       ROUND(100.0 * count_if(t.tag_name IS NOT NULL) / nullif(count(*), 0), 1) AS value
FROM   dwh_prod.information_schema.columns c
LEFT   JOIN dwh_prod.information_schema.column_tags t
       ON  t.schema_name = c.table_schema
       AND t.table_name  = c.table_name
       AND t.column_name = c.column_name
       AND t.tag_name    = 'pii'
WHERE  c.table_schema = 'gold'
  AND  c.column_name IN ('email', 'phone', 'street')

UNION ALL
-- 2. ownership decay: production objects owned by a human, not a group.
SELECT 'tables_owned_by_individual',
       count(*)
FROM   dwh_prod.information_schema.tables
WHERE  table_schema IN ('silver', 'gold')
  AND  table_owner LIKE '%@%'          -- a group name has no @

UNION ALL
-- 3. discoverability: gold tables shipped with no COMMENT.
SELECT 'gold_tables_without_comment',
       count_if(comment IS NULL OR trim(comment) = '')
FROM   dwh_prod.information_schema.tables
WHERE  table_schema = 'gold'

UNION ALL
-- 4. staleness: gold tables not written in 48h. Freshness is governance too --
--    an abandoned table is a table nobody can vouch for.
SELECT 'gold_tables_stale_48h',
       count_if(last_altered < current_timestamp() - INTERVAL 48 HOURS)
FROM   dwh_prod.information_schema.tables
WHERE  table_schema = 'gold';
Why here
The failure this prevents is treating governance as configuration — something you set up in month two and then consider done. It is not a state, it is a drift problem. New gold tables appear untagged. Exemptions granted for one migration are never withdrawn. An owner leaves and their objects keep their owner. None of that raises an error, and every one of it is invisible until an audit or a leak.
So the answer to "what do we do with it beyond looking at dashboards" is that a posture number is worthless on its own and useful in three specific mechanisms: as a trend, as a queue, and as a gate. A dashboard nobody has attached to a decision is a screenshot in a steering pack.
Three ways a posture number changes behaviour
MechanismHow it worksWhat it prevents
TrendSnapshot the four metrics weekly into a Delta table. The review reads the delta, not the level.The absolute number is always arguable and always argued. A number that got worse this week is not.
QueueEvery non-zero count becomes a ticket with a named owner and a date — not a slide.Findings that are reported repeatedly and fixed never. If nothing is assigned, nothing changes.
GateCoverage must be 100% and individual-owned tables 0 before a new domain is allowed into dwh_prod.Governance debt entering production faster than it is repaid, which is the only way it ever accumulates.
Doing it
  • Schedule the posture query weekly and append to a Delta table — a snapshot you can trend beats a console you have to open.
  • Make the release gate a CI check against the same SQL, so a new domain cannot enter dwh_prod with untagged personal columns.
  • Turn every non-zero metric into a ticket with an owner and a date on the day it appears, not at the quarterly review.
  • When Governance Hub access arrives, reconcile its numbers against yours before trusting either — a disagreement is a finding about your query or their scope, and you want to know which.
Embedding it
  • Give the posture query to the team as their artefact and let them add the fifth metric. The one they add tells you what they are actually worried about.
  • Chair the first governance review, then hand the chair over and attend the second as a guest. Governance that needs the consultant in the room has not transferred.
  • When a metric goes the wrong way, resist fixing it — assign it. The point of the queue is that it has owners who are not you.
  • Ask them what they would do differently if Governance Hub arrived tomorrow. If the honest answer is 'nothing, we would just read it there', the loop is genuinely theirs.
Defaults
  • Compute posture yourself today; treat any console as a nicer view over numbers you already own.
  • Trend the metrics, do not just level-check them — the delta is the reviewable thing.
  • Every non-zero metric has a named owner and a date, or it is not being governed.
  • Coverage is a release gate on new domains, enforced in CI, not a quarterly conversation.
  • Nothing in a signed permissions concept depends on a private-preview feature.
Gotchas
  • Waiting for Governance Hub before starting governance operations. Nothing is being waited for — an account admin can enable it from the Previews page today — and a team that arrives at GA with a console and no process to point it at has gained a screenshot, not governance.
  • Repeating "it is private preview, we cannot get it". That was the announcement wording; the documentation says Beta with account-admin self-enable. The cost of the stale version is a quarter of deferred governance work justified by a blocker that does not exist.
  • Designing a signed client deliverable around it. Beta means the shape can still change, so a permissions concept that references it acquires a dependency you cannot date — enable it, use it, but govern with the GA controls.
  • Treating the posture number as the deliverable. A dashboard with no owner and no gate is reported quarterly, admired, and never acted on — the metric stays flat for a year and everyone stops reading it.
  • Measuring coverage against dim_customer's column list instead of the tag vocabulary. Scoped that way it is 100% forever and silently misses the day a new gold table grows an email column, which is the only event it existed to catch.
  • table_owner LIKE '%@%' is a heuristic, not a rule. It assumes groups are not named with an email address; check the client's naming before you trust the count, because a group called data-eng@corp.example makes this metric read zero forever.

Straight to the documentation

The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.

Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register.