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.
The verb does not exist. Edits appear as duplicate
createPolicy rowsThe receipt:
GROUP BY policy_name HAVING COUNT(*) > 1
- 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 TABLElooped againstinformation_schema.tables, or the Unity Catalog REST API, for the inventory ·system.access.auditfor the change trail- Documented verbs
createPolicy,deletePolicy,getPolicy,listPolicies. NoupdatePolicy. NoapplyPolicy.- Sources
- Databricks ABAC GA docs · ABAC policies reference · ABAC requirements · system.access.audit schema
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.
-- 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 labelledcreatePolicy. There is noupdatePolicyevent, 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:
-- 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.
createPolicy— emitted on everyCREATE POLICYand everyCREATE OR REPLACE POLICY. The same verb covers both the new and the re-created case; the audit row is identical in shape.deletePolicy— emitted onDROP 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.
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.
-- 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
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.
-- 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:
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.
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
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:
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;
| 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
createPolicy → deletePolicy
→ createPolicy 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.
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 duplicate check is a heuristic, not a diff. Two
createPolicyrows 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 theWHENcondition was relaxed. The diff lives inrequest_paramson 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.auditrecords 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
- Reading the
request_paramsJSON diff. The duplicate-createPolicycheck 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
WHEREpredicates 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.
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.
- Databricks docs · Unity Catalog ABAC — GA May 13, 2026, 10,000-policy metastore quota, tag → policy → UDF model, no information schema view
- Databricks docs · ABAC policies —
CREATE [OR REPLACE] POLICYsyntax,COLUMN MASKform, principals clause, noUPDATE POLICY - Databricks docs · ABAC requirements — Serverless or DBR 16.4+ Standard, quotas (100/catalog, 50/table, 20 principals/policy)
- Databricks docs ·
system.access.audit— documented verbs (createPolicy,deletePolicy,getPolicy,listPolicies);request_paramsshape - Databricks docs ·
SHOW EFFECTIVE POLICIES ON TABLE— returnspolicy_name | policy_type | function | principals; the inventory surface
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
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.
The verb does not exist on
system.access.auditEdits surface as duplicate
createPolicy rows on the same name
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.
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.
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.
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.
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.
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
- The full inventory loop. The 47-column printout shape that
SHOW EFFECTIVE POLICIES ON TABLEreturns when concatenated acrossinformation_schema.tables, joined tocolumn_tagsfor the tag column theSHOWdoes 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
createPolicy→deletePolicy→createPolicyagainst the same name — the signature of widening a body by deleting and re-authoring it.