Crosshire
Tech · Databricks · Governance & Security · 8 min read · 6 July 2026

The Databricks security findings your console won't surface.

Your risk register is scattered across audit logs and information_schema, and nothing in the console ranks it — the createGrant that slipped a service principal into admins sits in the same flat feed as every routine one. Four questions, four plain SELECTs over Unity Catalog system.* tables, no dashboard required.

Four questions · one SELECT each
01Nothing ranks your risk register

Your risk register is scattered across two systems. Neither one ranks it for you.

Databricks gives you an audit log viewer and a metadata browser. Neither is a risk register. The audit viewer shows you one event at a time, newest first — it will not group forty createGrant calls by who made them and flag the one that added a service principal to admins. The catalog browser shows you one table's tags and masks at a time — it will not cross every tagged column against every masked column and hand you the gap. Both surfaces are honest about what happened. Neither one tells you what matters most.

Ranking requires a join, and a join is not a button.

This domain is one slice of the companion Databricks query library, the receipts unbundled — full count and GitHub link in the closing section.

This post walks the four questions that come up first in every account review, and the exact query that answers each — no dashboard, no login, just SQL you can run against your own metastore and keep.

Questions
4
the ones nothing in the console ranks
System tables
9
audit · information_schema · lineage · network
Method
SELECT
no app, no live connection
Full library
96
every query, open on GitHub
Provenance

Every figure a reader produces here is read from Databricks Unity Catalog system.* tables by a plain SELECT — no application, no dashboard, no live connection. This domain carries no dollar figures; where the wider library does price something, it uses system.billing.list_prices (effective_list) — DBU-only, est · at list, directional. Empty results are treated as not assessed, never as zero or as "clean."

Availability varies by account. Not every system.* table exists on every account — several are Public or Private Preview, feature-gated, or must be enabled per-metastore by an admin. A table that isn't enabled returns TABLE_OR_VIEW_NOT_FOUND, which reads as not enabled, not zero.

The console will show you a column exists. It will not tell you the column is tagged sensitive and sitting there unguarded.
02Unmasked, but tagged sensitive?

"Which sensitive columns are classified but unmasked?" Unity Catalog lets you tag a column PII and never mask it — silently.

Tagging and masking are two separate acts in Unity Catalog, tracked in two separate information_schema views, and nothing cross-checks them for you. A column can carry a pii or confidential tag in column_tags for months while column_masks has no matching row — the classification exists, the enforcement never landed. The catalog browser will show you the tag on that one column if you already know to look at it. It will not show you every tagged-but-unmasked column across every schema in one screen.

SELECT
  ct.catalog_name,
  ct.schema_name,
  ct.table_name,
  ct.column_name,
  ct.tag_name,
  ct.tag_value
FROM system.information_schema.column_tags ct
LEFT JOIN system.information_schema.column_masks cm
  ON  cm.catalog_name = ct.catalog_name
  AND cm.schema_name  = ct.schema_name
  AND cm.table_name   = ct.table_name
  AND cm.column_name  = ct.column_name
WHERE lower(ct.tag_name) IN ('pii', 'sensitive', 'confidential', 'pci', 'phi')
  AND cm.column_name IS NULL
ORDER BY ct.catalog_name, ct.schema_name, ct.table_name;

The LEFT JOIN ... IS NULL is the whole trick: keep every tagged row, drop the ones that also resolved to a mask. What is left is classification without enforcement. Read it like this: one row per unmasked sensitive column — a result like sales.customers.national_id tagged pii with no row in column_masks at all. The same gap exists one level up: system.information_schema.row_filters is the row-level analogue of column_masks, and table_tags the analogue of column_tags — join those two the same way for tables that are classified but carry no row filter.

03Who escalated privilege?

"Who escalated privilege or changed admin roles?" Grants and group changes are two different action_names. The console never asks you to watch both.

system.access.audit logs every grant, every revoke, every group membership change as its own row, one action_name at a time. The audit log viewer will happily show you all of them in a scrolling list — it will not filter that list down to the subset that actually moved somebody's blast radius: a new grant on a securable, or a principal added to admins. You have to know both action families and ask for them together.

SELECT
  event_date,
  user_identity.email                                AS actor,
  action_name,
  element_at(request_params, 'granteePrincipal')      AS grantee,
  element_at(request_params, 'securable_full_name')   AS securable,
  element_at(request_params, 'targetGroupName')       AS group_name,
  response.statusCode                                 AS status_code
FROM system.access.audit
WHERE event_date >= dateadd(day, -30, current_date())
  AND event_date <  current_date()
  AND ( action_name IN ('createGrant', 'removeGrant')
     OR ( action_name IN ('addPrincipalToGroup', 'removePrincipalFromGroup')
          AND element_at(request_params, 'targetGroupName') = 'admins' ) )
ORDER BY event_date DESC;

Before you trust this result:

  • request_params is a MAP<STRING, STRING> whose keys vary by action_name and service — the grant events carry securable_full_name, the group events carry targetGroupName, and neither key exists on the other. Treat the key names above as representative, not verified against every account's audit schema — check your own with the query below before you trust them.
  • The action_name list itself is representative of grant and admin-role changes, not an exhaustive enumeration — widen it if your account surfaces other action names for the same effect.
  • Whether an admin-role change even reaches this table depends on scope: a promotion made at account scope inside a workspace whose audit events log at workspace scope only won't appear here at all.
SELECT DISTINCT action_name, map_keys(request_params)
FROM system.access.audit
WHERE action_name IN ('createGrant', 'removeGrant');

Read it like this: a short, dated list — actor, what they granted or who they added, to what. The finding is rarely the volume; it's the one row where the actor and the grantee don't match anyone on the change-approval thread.

04Is PII leaking downstream?

"Where is PII leaking into untagged columns?" A tag on the source table says nothing about what a downstream job named the copy.

Tags don't propagate through a pipeline. A column tagged pii in a bronze table can feed a dozen silver and gold columns downstream, renamed, recast, joined into a wider table — and none of those descendants inherit the tag automatically. system.access.column_lineage records the source-to-target edges Unity Catalog observed; column_tags records what's classified. Neither table alone answers "did the PII escape its label" — the answer is the join between them.

WITH tagged AS (
  SELECT DISTINCT catalog_name, schema_name, table_name, column_name
  FROM system.information_schema.column_tags
  WHERE lower(tag_name) IN ('pii', 'sensitive', 'confidential')
)
SELECT
  cl.source_table_full_name,
  cl.source_column_name,
  cl.target_table_full_name,
  cl.target_column_name,
  cl.event_time
FROM system.access.column_lineage cl
JOIN tagged src
  ON  src.catalog_name = cl.source_table_catalog
  AND src.schema_name  = cl.source_table_schema
  AND src.table_name   = cl.source_table_name
  AND src.column_name  = cl.source_column_name
LEFT JOIN system.information_schema.column_tags dst
  ON  dst.catalog_name = cl.target_table_catalog
  AND dst.schema_name  = cl.target_table_schema
  AND dst.table_name   = cl.target_table_name
  AND dst.column_name  = cl.target_column_name
WHERE dst.column_name IS NULL
ORDER BY cl.event_time DESC;

system.access.column_lineage already carries the catalog / schema / table as separate columns (source_table_catalog, source_table_schema, source_table_name, and the matching target_* triple) right alongside the dotted source_table_full_name / target_table_full_name strings — join on those native columns directly instead of parsing the full name with split_part. Read it like this: one row per tagged-source-to-untagged-target edge, most recent first — a result like bronze.raw.customers.email feeding gold.marketing.contact_list.primary_contact, the latter carrying no tag at all. Each row is a column somebody has to either tag or stop copying.

05What got blocked?

"What got blocked — at the API, the ingress, and the egress?" Audit logs cover the API tier. A request stopped at the network edge may never reach it.

This table can't see everything, and the gap matters. system.access.audit logs calls that reached Databricks' API layer.

Before you trust this result:

  • A request an IP access list or a private-networking boundary stops before that layer simply never generates a row here.
  • What the audit log can show you is the traffic that reached the API and was rejected there — repeated 401/403 responses from a source that shouldn't be calling at all — alongside every change to the IP allow/deny list itself, so you can see who last touched the perimeter next to what happened after.
  • This is still the API tier: the traffic that never made it that far lives in the two tables below, and the section title isn't answered until you've run those too.
SELECT
  event_date,
  service_name,
  action_name,
  source_ip_address,
  response.statusCode     AS status_code,
  response.errorMessage   AS error_message,
  COUNT(*)                 AS attempts
FROM system.access.audit
WHERE event_date >= dateadd(day, -30, current_date())
  AND event_date <  current_date()
  AND ( response.statusCode IN (401, 403)
     OR action_name IN ('createIpAccessList', 'updateIpAccessList', 'deleteIpAccessList') )
GROUP BY 1, 2, 3, 4, 5, 6
ORDER BY attempts DESC
LIMIT 100;

The console's audit viewer shows every one of these rows individually, oldest or newest first — it will not group repeated 403s from the same source_ip_address into a single line, and it will not put "somebody edited the IP access list Tuesday night" next to "denied attempts from a new address stopped Wednesday morning" so you can connect the two.

Read it like this: a short table, worst offender first — a source hammering the API with rejected calls, or a gap in the list-edit history exactly where the denied traffic starts. Read a clean result the same way you'd read an empty node_timeline: as evidence the network layer never let the traffic through to be logged, not as evidence nothing happened.

Inbound: what the ingress policy denied

system.access.inbound_network is the ingress-policy analogue of the audit log — a request an IP access list or private-link boundary rejects before it reaches the API tier lands here, not in system.access.audit.

SELECT
  policy_outcome,
  rule_label,
  request_path,
  authenticated_as,
  COUNT(*)         AS denials,
  MIN(event_time)  AS first_seen,
  MAX(event_time)  AS last_seen
FROM system.access.inbound_network
WHERE event_time >= current_timestamp() - INTERVAL 30 DAYS
  AND policy_outcome IN ('DENY', 'DENY_DRY_RUN')
GROUP BY 1, 2, 3, 4
ORDER BY denials DESC;

Before you trust this result:

  • It is Preview and regional, so it may not exist on every account or every region yet.
  • It carries denials only — filter policy_outcome down to DENY/DENY_DRY_RUN explicitly, since the underlying table also carries allowed traffic.
  • Retention is capped at 30 days regardless of how far back you ask — a 90-day window silently truncates to 30.

Read it like this: a short table, worst offender first — a rule_label firing repeatedly against one request_path, or a dry-run rule that would have blocked traffic your enforcement policy is still letting through.

Outbound: what the egress policy denied

system.access.outbound_network is the egress side — traffic your workspace tried to send out that a network policy stopped, the exfiltration-shaped question the API-tier audit log was never going to answer.

SELECT
  network_source_type,
  destination_type,
  access_type,
  destination,
  dns_event.rcode                AS dns_rcode,
  storage_event.rejection_reason AS storage_rejection_reason,
  COUNT(*)                       AS denials
FROM system.access.outbound_network
WHERE event_time >= current_timestamp() - INTERVAL 30 DAYS
GROUP BY 1, 2, 3, 4, 5, 6
ORDER BY denials DESC;

Before you trust this result:

  • This table is denials-only by design — there's no allowed-traffic baseline in it, so you can't compute an allow/deny ratio from it alone.
  • dns_event is NULL unless destination_type is DNS; storage_event is NULL unless it's STORAGE — expect most rows to carry exactly one of the two.
  • Retention runs 365 days, longer than the inbound side, and it's regional like its inbound counterpart.

Read it like this: a short table, worst offender first — a destination your egress policy keeps rejecting, or repeated NXDOMAIN lookups. An empty result here means no egress policy is configured, or the feature isn't populated yet — not that nothing tried to leave.

06The receipt

The receipt. Ranking a risk register is a join, not a feature request.

Four questions, four plain SELECTs — none of them needed a security vendor, a SIEM export, or a ticket asking Databricks to "please add a governance dashboard." The join was always available in system.*; the console just never ran it for you. Run these against your own metastore and the risk register stops being a scattered set of tabs — it's a ranked list you can hand to whoever owns the finding.

These four are one domain. The full set — the query library — is ninety-six SELECTs across cost, performance, compute, jobs, serving, storage, and governance, seventeen of them in this domain alone, all open on GitHub. The Databricks audit reference on Crosshire Learn walks the same system tables end to end if you want the fuller course rather than the field-note version.

Want a governance sweep run against your own metastore? Start a conversation →
· · ·

Every query reads Databricks Unity Catalog system.* tables via plain SELECT; answers describe row shapes, not client data. Empty is treated as not assessed. — Crosshire

Further reading
  1. Databricks query library: the receipts, unbundled — the full set: all 96 SELECTs across seven domains, including the four in this post.
  2. crosshire-audit-databricks-admin — the source on GitHub: self-contained .sql files you run in a Databricks SQL editor.
  3. Jobs on the expensive clock — the same receipts thesis applied to the Jobs & Workflows domain.
  4. What your tables actually weigh — the Storage domain: file counts, small-file drag, and orphaned data.
  5. Idle serving endpoints — the Model Serving domain: provisioned throughput nobody's calling.
  6. Databricks audit reference — Crosshire Learn's course treatment of the same system.access.audit surface.
  7. Crosshire Audit: your warehouse, with receipts — the Snowflake sibling: the same receipts thesis, one export, one dashboard.
  8. Databricks system tables reference — Databricks' own documentation for the system.* schemas these queries read.
  9. Crosshire consulting — data platform audit engagements with the same rigour as this library.
D
writes Crosshire Journal · crosshire.ch · July 2026