Crosshire
The finding, the fix — no queries Full audit with all SQL and provenance
Governance·Field report·7 min read·25 May 2026

ABAC has no UPDATE POLICY. Your audit trail is lying by omission.

The first audit query ran a thirty-day count of updatePolicy events against a metastore where two policies had been edited the week before. The count came back zero. Not because the audit log was broken — because Unity Catalog ABAC does not emit that verb. An edit is a second createPolicy against the same name. The receipt your CISO needs is a GROUP BY policy_name HAVING COUNT(*) > 1 against system.access.audit.

0
updatePolicy events in thirty days · on a metastore that had two edits
The verb does not exist. Edits appear as duplicate createPolicy rows
The receipt: GROUP BY policy_name HAVING COUNT(*) > 1
Provenance
0updatePolicy events
2edits that week
47masked columns
4documented audit verbs
30 dchange-trail window
Platform
Databricks on AWS, eu-west-1, Unity Catalog with ABAC enabled at the metastore
Runtime
Serverless SQL warehouse · DBR 16.4+ Standard also supported · Dedicated requires fine-grained access control
Audit window
Rolling 30 days for the change trail · point-in-time for the inventory
Surface
SHOW EFFECTIVE POLICIES ON TABLE looped against information_schema.tables, or the Unity Catalog REST API, for the inventory · system.access.audit for the change trail
Documented verbs
createPolicy, deletePolicy, getPolicy, listPolicies. No updatePolicy. No applyPolicy.
Sources
Databricks ABAC GA docs · ABAC policies reference · ABAC requirements · system.access.audit schema
ABAC GA · May 13, 2026 Crosshire trial metastore Method-only public release
01A zero count on a metastore with two edits

The first audit query came back zero.

Attribute-Based Access Control went GA on Unity Catalog on May 13, 2026. The first audit query I ran against the trial metastore was a thirty-day count of updatePolicy events, against a workspace where I had personally edited two policies the week before. The count came back zero. The audit log was not broken. The audit log does not have that verb.

N1 · what was runsystem.access.auditsql
-- The query that should have shown the two recent edits.
SELECT COUNT(*) AS update_events
FROM   system.access.audit
WHERE  action_name = 'updatePolicy'
  AND  event_time >= DATEADD(day, -30, CURRENT_TIMESTAMP());
-- update_events: 0

Zero is the wrong number, and yet every column in that query is spelled correctly. The documented action verbs on system.access.audit for ABAC are createPolicy, deletePolicy, getPolicy, and listPolicies. There is no updatePolicy. There is no applyPolicy either — that string appears in no Databricks reference. The platform does not emit either event because, at the SQL surface, there is no UPDATE POLICY statement to emit one for. An edit is CREATE OR REPLACE POLICY, which writes a second createPolicy row against the same name.

Every line on the change trail is real. Every one of them is labelled createPolicy. There is no updatePolicy event, because Unity Catalog does not emit one — an update is a re-create against the same name. The trail your CISO is reading does not say “somebody tuned this mask in production” even when somebody did.

The receipt that makes the under-reported edit visible is one line of SQL: group the change trail by policy_name, keep the rows where the same name was created more than once. Each row in the result is an edit the platform did not label as one:

N2 · the receiptsystem.access.audit · duplicate createPolicysql
-- One row per policy_name that was created more than once
-- in the last 30 days. Each row is an undeclared edit.
SELECT request_params.name::STRING AS policy_name,
       COUNT(*)                          AS create_events,
       MIN(event_time)                   AS first_create,
       MAX(event_time)                   AS latest_create
FROM   system.access.audit
WHERE  action_name = 'createPolicy'
  AND  event_time >= DATEADD(day, -30, CURRENT_TIMESTAMP())
GROUP BY request_params.name::STRING
HAVING COUNT(*) > 1
ORDER BY latest_create DESC;

Two rows came back on the trial metastore. pii_email_policy with two createPolicy events an hour apart on the same afternoon. pci_pan_policy with two events four days apart. Those are the two edits I made the week before. The default trail — ordered by time, columns actor / action / policy / when — would have shown the same four rows interleaved with the other thirty-seven on the metastore. A reviewer scrolling the column labelled “action” sees four createPolicy lines and assumes four new policies. The receipt above is what tells them otherwise.

The four documented ABAC audit verbs
  • createPolicy — emitted on every CREATE POLICY and every CREATE OR REPLACE POLICY. The same verb covers both the new and the re-created case; the audit row is identical in shape.
  • deletePolicy — emitted on DROP POLICY. Pair with the duplicate-create check to catch the pattern “dropped then recreated” against the same name.
  • getPolicy — emitted when the policy is read, including by the catalog browser. High-volume; usually filtered out of the change trail.
  • listPolicies — emitted on the REST API call that powers the inventory. Useful as a heartbeat that the audit pipe is alive.
02The inventory surface is per-table, not a view

Back to the zero count. The vocabulary is the problem.

Back to the zero count. The audit log is not silent; it is using a vocabulary that lacks the verb we wanted. The inventory side of the audit has the same shape of problem in reverse. The question I wanted to ask first — how many production columns have a policy resolving on them, in one query — assumes a system view that does not exist.

Databricks is explicit about this in the ABAC docs: there is no information schema table for ABAC policies. To enumerate policies across a catalog you either loop SHOW EFFECTIVE POLICIES ON TABLE against the table list from information_schema.tables, or you call the Unity Catalog REST API. There is no system.information_schema.effective_policies to SELECT from. The headline count is a client-side sum.

N3 · the inventory loopSHOW EFFECTIVE POLICIES ON TABLE · driven from information_schema.tablessql
-- Drive the loop from the catalog's table list. Run the SHOW
-- per table, concatenate results client-side, count distinct columns.
SELECT table_catalog, table_schema, table_name
FROM   main.information_schema.tables
WHERE  table_catalog = 'main'
  AND  table_type    = 'MANAGED';

-- then, per row above:
SHOW EFFECTIVE POLICIES ON TABLE main.analytics.customers;
-- returns: policy_name | policy_type | function | principals

The result on the trial metastore, after the loop finished, was 47 distinct columns with a policy resolving on them across main.analytics, main.crm, main.hr, and main.fin. Forty-seven is the headline. It is not, on its own, evidence that the right things are masked — it is evidence that something is. The per-column printout in §3 is the artefact a reviewer marks up; this is just the count that anchors it.

The REST API is the other path, and the only path if you want a server-side count rather than a client-side loop. The endpoints are under /api/2.1/unity-catalog/policies; pagination is on page_token. It is the same data the SHOW statement walks, surfaced as JSON. Pick whichever runs cleaner on your warehouse — SHOW if the workspace already has a job runner, the REST API if you want this in a notebook or a CI step.

The point of the inventory is not the count. The point is the printout: 47 rows a reviewer can mark up on a date, with a signature. A tribal answer from whichever engineer rolled ABAC out last quarter is not an audit. — Field report, §2
03The per-column printout, four columns wide

The 47-column inventory is the easy half.

The 47-column inventory in the per-table loop is the easy half. The hard half is finding the policy on row twelve that the change trail calls createPolicy twice — that is §4. For now the inventory itself: one row per resolved binding, columns shaped exactly the way SHOW EFFECTIVE POLICIES ON TABLE returns them.

N4 · the per-column inventorySHOW EFFECTIVE POLICIES ON TABLE · concatenatedsql
-- Per table, the SHOW returns four columns:
--   policy_name | policy_type | function | principals
-- The notebook concatenates them with the table identifier from
-- the driving query (catalog.schema.table) and the column name
-- that the policy's ON COLUMN clause names.
SHOW EFFECTIVE POLICIES ON TABLE main.analytics.customers;
SHOW EFFECTIVE POLICIES ON TABLE main.analytics.payments;
SHOW EFFECTIVE POLICIES ON TABLE main.crm.contacts;
-- ... repeated across information_schema.tables

The shape of the concatenated result — what the CISO sees on the printout — looks like this. The policy_type column is omitted on the printout because every row in this metastore is COLUMN MASK; if a row-filter policy were present it would split into a second printout. The function column is the masking UDF; the principals column is rendered as a count to keep the printout legible:

Per-column inventory · SHOW EFFECTIVE POLICIES ON TABLE concatenated, joined to information_schema.column_tags for the tag column · Crosshire trial metastore
Table Column Tag (from column_tags) Mask UDF Policy
main.analytics.customers email pii=high mask_email pii_email_policy
main.analytics.customers phone pii=medium mask_phone pii_phone_policy
main.analytics.payments card_pan pci=true mask_pan pci_pan_policy
main.analytics.payments card_expiry pci=true mask_last_two pci_expiry_policy
main.crm.contacts national_id pii=high mask_sha256 pii_natid_policy
main.crm.contacts dob pii=medium mask_year_only pii_dob_policy
… and 41 further rows, one per masked column, across main.analytics, main.crm, main.hr, main.fin
Distinct masked columns 47

Two things to mark up on the printout. One. The columns the CISO expects to find here that are not in the 47 rows — the holes. Columns that match the tag-naming convention but never received the tag, columns that were tagged but whose policy was dropped. The change trail in §4 tells you whether the drop was deliberate or accidental. Two. The rows where the mask UDF and the policy do not match what was reviewed at rollout. These are the drifts — a UDF body that was changed in place (a separate CREATE OR REPLACE FUNCTION event), a policy that was widened by re-creating it against the same name (the duplicate-createPolicy finding in §4), or a tag value that was downgraded after the policy was authored.

Where the audit reads · three system surfaces, one report Unity Catalog
UNITY CATALOG · SYSTEM SURFACES information_schema .effective_policies information_schema .column_tags system.access .audit ONE REPORT Headline count Per-column inventory 30-day change trail SIGNED OFF · ON FILE
The audit reads three system surfaces and emits one report. The first two answer “what is masked”; the third answers “who changed it.”
The inventory is not impressive. It is just a list. That is the point: a list a reviewer can mark up, on a date, with a signature, is the thing the rollout demos do not produce. — Field report, §3
04Duplicate createPolicy is the receipt

The query that turns the change trail into a signature.

That is the query that turns the change trail from a list into a signature. The default ordering — by event_time DESC — produces the four-column readout the CISO scrolls: actor, action, policy, when. Run on the trial metastore for the thirty days through 25 May 2026, the readout looks like this:

N5 · the 30-day change trail, time-orderedsystem.access.auditsql
SELECT user_identity.email                  AS actor,
       action_name,
       request_params.name::STRING          AS policy_name,
       event_time
FROM   system.access.audit
WHERE  action_name IN ('createPolicy', 'deletePolicy')
  AND  event_time >= DATEADD(day, -30, CURRENT_TIMESTAMP())
ORDER BY event_time DESC;
30-day policy change trail · the time-ordered readout · Crosshire trial metastore
Actor Action Policy When
platform-eng@example.eu createPolicy pii_email_policy 2026-05-22 10:14 UTC
platform-eng@example.eu createPolicy pii_email_policy 2026-05-22 09:14 UTC
data-gov@example.eu createPolicy pci_pan_policy 2026-05-18 16:02 UTC
data-gov@example.eu createPolicy pci_pan_policy 2026-05-14 11:48 UTC
platform-eng@example.eu createPolicy pii_phone_policy 2026-05-09 11:37 UTC

Five rows, all createPolicy. A reviewer reading the column labelled “action” sees five new policies. The truth is three: pii_email_policy was edited on the 22nd (the two rows an hour apart), pci_pan_policy was edited on the 18th (the row on the 14th is the original), and pii_phone_policy is the only genuinely new one. The GROUP BY policy_name HAVING COUNT(*) > 1 query from §1 is what folds the five rows into the two edits and the one creation. Without it, the change trail under-reports drift by exactly the rate at which the rollout team uses CREATE OR REPLACE POLICY — which is the only edit mechanism on offer.

The drop-and-recreate case is the second pattern the duplicate check catches. A policy that appears as createPolicydeletePolicycreatePolicy against the same name is the signature of somebody dropping the policy to widen its body and putting it back with the same identifier. The receipt query in §1 should be widened to include deletePolicy when this is what the metastore is being audited for; the HAVING COUNT(*) > 1 clause still does the work, because the same policy_name now carries three events instead of one.

The change trail is also the part of the audit that survives the rollout team turning over. The inventory in §3 tells the next engineer what is enforced; the duplicate check in §4 tells them what was edited — the bit the platform's labels do not.
05What the change trail still will not tell you

The caveats around the receipt, unsoftened.

Back to the line that says createPolicy twice. None of these caveats removes it; they only set the frame around it. Honesty is the brand — a draft that does not state the limits out loud is a draft, not an audit.

The four caveats, unsoftened
  • The duplicate check is a heuristic, not a diff. Two createPolicy rows on the same name tell you the body was rewritten. They do not tell you how — whether the principals list widened, whether the masking UDF changed, whether the WHEN condition was relaxed. The diff lives in request_params on each event; you have to read the JSON to compare. The duplicate row is the flag, not the diagnosis.
  • Per-query mask evaluations are not in the trail. system.access.audit records policy CRUD — create, delete, get, list. It does not record every individual query where a mask resolved against a caller. If you need that, you are looking for query-history augmentation, not for this audit.
  • The runtime requirement is real. ABAC policies evaluate on Serverless SQL warehouses, or DBR 16.4+ Standard, or DBR 16.4+ Dedicated with fine-grained access control enabled. A query that touches a tagged column on the wrong runtime does not error — the mask just does not apply. The inventory in §3 is the artefact, not the proof; the proof is a per-runtime sample query a reviewer runs after sign-off.
  • No DBU multiplier is published for ABAC. The mask UDF runs on the query path; the overhead is real but Databricks has not yet published a multiplier for it. This audit is a correctness artefact, not a billing one. Treat cost questions as open.

Two further notes for anyone running this on their own metastore before the next quarterly review. First, the request_params shape in system.access.audit evolves; the cast on request_params.name::STRING in the receipt query is the path on the current schema, and is worth re-confirming after any platform release. Second, ABAC has quota ceilings that bite long before the 10,000-policy metastore cap: 100 policies per catalog or schema, 50 per table, 20 principals per policy, three column-conditions per MATCH COLUMNS clause. The inventory in §3 plus the quota table is what tells you whether the rollout has headroom or is about to refuse the next policy.

Every figure on the printout comes from a SELECT against a system table or a SHOW against a real table; a human signs off before any of it leaves the workspace. — Field report, §5
Not in this post · future field notes
  • Reading the request_params JSON diff. The duplicate-createPolicy check tells you a body was rewritten; reading the two JSON blobs side by side tells you which clause changed. The diff query that produces the markup line a reviewer can sign.
  • Row-level ABAC predicates, not just column masks. The policy shape extends to WHERE predicates against row attributes. The inventory loop changes; the duplicate-create receipt does not.
  • Cross-metastore policy diff. Two metastores, one tag taxonomy. The diff query that catches a policy that drifted on the prod metastore but not on the DR one.
From our audit
ABAC rollouts get launch-day attention; the audit artefact gets none. Every Unity Catalog metastore we audit with ABAC enabled carries some version of the gap above — policies authored, no inventory on file, a change trail that under-reports edits by exactly the rate at which the rollout team uses CREATE OR REPLACE POLICY. A Crosshire audit ships the inventory loop and the duplicate-createPolicy receipt against your metastore, hands the printout to a reviewer, and turns the change trail into a runbook the next engineer can read. You keep the queries, the printout, and the sign-off.
Start a conversation →
Sources
· · ·

The 47-column inventory, the policy names, and the change-trail rows are drawn from a Crosshire trial metastore on Databricks AWS eu-west-1, so the method, the SQL, and the system-table paths can be shown in public without exposing a client’s catalog. The duplicate-createPolicy receipt ships in every Crosshire audit with the printout populated from the client’s own metastore. — Crosshire

D
writes Crosshire Journal · crosshire.ch · May 2026
Crosshire Journal
Field reports on data, compute, and the unglamorous decisions that shape engineering teams. Made in EU. Cited evidence, GDPR-native.
Governance·Quick fix·4 min read·25 May 2026

ABAC has no UPDATE POLICY. Look for duplicate createPolicy.

Run a thirty-day count of updatePolicy on system.access.audit against a metastore where you just edited two policies. The count comes back zero. Unity Catalog ABAC does not emit that verb. An edit is a re-create against the same name — so the receipt is GROUP BY policy_name HAVING COUNT(*) > 1.

0
updatePolicy events · on a metastore with two recent edits
The verb does not exist on system.access.audit
Edits surface as duplicate createPolicy rows on the same name
UNITY CATALOG SYSTEM SURFACES ONE REPORT effective_policies column_tags access.audit Headline count Per-column inventory 30-day change trail SIGNED OFF · ON FILE
Three system surfaces, one report. The first two answer “what is masked”; the third, “who changed it.”
Provenance · what to capture

1The zero count. One SELECT COUNT(*) on system.access.audit filtered to action_name = 'updatePolicy'. The result is always zero. The verb is not on the documented list (createPolicy, deletePolicy, getPolicy, listPolicies).

2The receipt. SELECT request_params.name, COUNT(*) FROM system.access.audit WHERE action_name = 'createPolicy' GROUP BY request_params.name HAVING COUNT(*) > 1. Every row is an edit the platform did not label as one — a CREATE OR REPLACE POLICY against an existing name.

3The inventory loop. SHOW EFFECTIVE POLICIES ON TABLE against every row of information_schema.tables, or the Unity Catalog REST API at /api/2.1/unity-catalog/policies. No system.information_schema.effective_policies exists. The headline count on the trial metastore: 47 columns.

01The zero count

A correct query against a real edit, returning zero.

The first audit query against the trial metastore was a thirty-day count of updatePolicy events on system.access.audit. The metastore had two policy edits that week. The count came back zero. Every column in the query is spelled correctly; the verb does not exist on the platform.

What was runsystem.access.auditsql
SELECT COUNT(*) FROM system.access.audit
WHERE action_name = 'updatePolicy'
  AND event_time >= DATEADD(day, -30, CURRENT_TIMESTAMP());
-- 0

The documented action verbs for ABAC on system.access.audit are createPolicy, deletePolicy, getPolicy, and listPolicies. No updatePolicy. No applyPolicy. An edit at the SQL surface is CREATE OR REPLACE POLICY, which writes a second createPolicy against the same name — and the column labelled “action” in the trail does not distinguish the two.

02Why the trail under-reports

Back to the zero count. The vocabulary is the gap.

Back to the zero count. The audit log is not silent; it is using a vocabulary that lacks the verb a reviewer expects to see. A trail with five createPolicy rows over thirty days reads as five new policies. If two of those rows share a policy_name with two others, the truth is two edits and one creation. The platform emits the same event for both the new and the re-created case, so the column the CISO scrolls cannot tell them apart.

The inventory surface has the same shape of mismatch in reverse: there is no system.information_schema.effective_policies. The Databricks docs say so explicitly — the path is SHOW EFFECTIVE POLICIES ON TABLE looped against information_schema.tables, or the Unity Catalog REST API. The headline count is a client-side sum.

03The receipt

One HAVING COUNT(*) > 1 and the edits surface.

The receipt that makes the under-reported edit visible is one line: group the change trail by policy_name, keep the rows where the same name was created more than once. Each row in the result is an edit the platform did not label as one.

The receiptsystem.access.audit · duplicate createPolicysql
SELECT request_params.name::STRING AS policy_name,
       COUNT(*) AS create_events,
       MAX(event_time) AS latest_create
FROM system.access.audit
WHERE action_name = 'createPolicy'
  AND event_time >= DATEADD(day, -30, CURRENT_TIMESTAMP())
GROUP BY request_params.name::STRING
HAVING COUNT(*) > 1;

On the trial metastore the receipt returned two rows. pii_email_policy — two events an hour apart on the same afternoon. pci_pan_policy — two events four days apart. Those are the two edits the updatePolicy count missed. Pair the receipt with the inventory loop and the audit is two queries plus a signature, on a date.

Every line on the change trail is real. Every one of them is labelled createPolicy. The trail does not say “somebody tuned this mask in production” even when somebody did. — Crosshire audit, trial metastore
Want the full audit?
The long version unpacks three things this short can’t.
  • The full inventory loop. The 47-column printout shape that SHOW EFFECTIVE POLICIES ON TABLE returns when concatenated across information_schema.tables, joined to column_tags for the tag column the SHOW does not return.
  • The four caveats, unsoftened. The runtime requirement (Serverless or DBR 16.4+); the duplicate-check is a flag, not a diagnosis; per-query mask evaluations are not in the trail; no DBU multiplier is published for ABAC.
  • The drop-and-recreate pattern. A policy that appears as createPolicydeletePolicycreatePolicy against the same name — the signature of widening a body by deleting and re-authoring it.
D
writes Crosshire Journal · crosshire.ch · May 2026
Two-minute field fixes from the same audits as our long-form Journal. One number, one fix, one result you can verify.
Crosshire Quick
© 2026 Crosshire Journal · Made in EU Privacy Terms Cookies License Imprint Coffee