Who can touch what, who did, and where sensitive data leaks its guardrails. Grants, column masks and row filters, tags, data classification, lineage blast-radius, network denials and run-as escalation.
War storyLocked-down grants, wide-open PIIThe access review passed. The data was still in the clear.service_name (e.g. accounts, accountsAccessControl, unityCatalog, vectorSearch), action_name (the operation; note action values are representative, not an exhaustive enum — group by it, never hardcode a filter list), user_identity struct → email / subject_name (the acting principal; subject_name, NOT subjectName; frequently NULL), source_ip_address, response.status_code (200 = success), identity_metadata.run_by / identity_metadata.run_as (for run-as escalation; usually NULL for ordinary single-user actions), request_params['endpoint_name'] (vector-search endpoint), event_time, event_date (partition column used for windowing).SELECT on system.access. Account-level events are global (workspace_id=0); workspace events are regional. ~15-minute ingest lag — treat the most recent hour as provisional.source_table_full_name / target_table_full_name, discrete source_table_catalog/schema/name + source_column_name (and target equivalents), entity_type, direct_access (true = direct read/write; false = indirect/view-expansion), created_by, event_time, event_date.SELECT on system.access. Regional. Subset by design — operations with no source (e.g. INSERT ... VALUES literals) emit no row, so it undercounts; report as coverage-bounded, never a proof of absence.source_table_full_name / target_table_full_name (source NULL on write-only events, target NULL on read-only), discrete source_table_catalog/schema/name, source_type / target_type, entity_type, direct_access, created_by, event_time, event_date.system.access.* is workspace-configurable). UC + SELECT on system.access. Regional. Empty lineage means "not captured" (MERGE/JDBC/path/temp-view gaps), NOT "unused."policy_outcome (DENY / DENY_DRY_RUN), rule_label, request_path, authenticated_as (principal), source.ip nested subfield (source IP — exact subfield name unverified), event_time (this table has no event_date column).network_source_type, destination_type, access_type, destination, dns_event.rcode (NULL unless destination_type=DNS), storage_event.rejection_reason (NULL unless destination_type=STORAGE), event_time (no event_date column).catalog_name, schema_name, table_name, column_name, class_tag (the detected sensitivity class), confidence (HIGH / LOW), data_type, frequency (float 0–1, share of sampled values matching), first_detected_time / latest_detected_time. The samples array<string> column is deliberately dropped (it holds raw sample values = live sensitive data).system.data_classification schema enabled — this is a *separate* schema from system.access, so enabling one does not enable the other. Covers enabled catalogs only; unclassified columns are simply absent. If disabled, treat as "feature not enabled," not empty.table_catalog, table_schema, table_name, column_name, mask_name, using_columns (columns the mask function reads).table_catalog, table_schema, table_name, filter_name, target_columns (columns the filter reads).catalog_name / schema_name / table_name / column_name (discrete identifiers — join with =, never LIKE CONCAT(...) since _ is a LIKE wildcard), tag_name, tag_value. Tag names/values are free-text and case-sensitive.data_classification.results.TAG_NAME, TAG_VALUE (plus the object identifier columns).CATALOG_TAGS / SCHEMA_TAGS / VOLUME_TAGS exist but are intentionally excluded here as unverified.)GRANTEE, PRIVILEGE_TYPE, TABLE_CATALOG / TABLE_SCHEMA / TABLE_NAME. (IS_GRANTABLE is always 'NO' / reserved — not collected.)SHOW GRANTS. Always label results "partial — privilege-aware."GRANTEE, PRIVILEGE_TYPE, CATALOG_NAME.table_privileges).GRANTEE, PRIVILEGE_TYPE (the only columns read; per-view object-name columns are inferred/unverified).GRANTEE, PRIVILEGE_TYPE.GRANTEE, PRIVILEGE_TYPE.STORAGE_CREDENTIAL_PRIVILEGES is deprecated and excluded.)GRANTEE, PRIVILEGE_TYPE.table_catalog, table_schema, table_name, table_type (filtered to MANAGED / EXTERNAL), table_owner, created, last_altered (can be NULL / late-populated → "age unknown").system.* schemas aren’t uniformly available — several (e.g. system.query, system.lakeflow) are still in preview or must be enabled per-metastore. A query that returns nothing usually means its schema isn’t enabled for your account yet.system.billing.list_prices) unless a query explicitly joins account_prices.A 30-day rollup of audited actions where the initiating principal (run_by) differs from the identity the action executed as (run_as), aggregated per service, action, and masked identity pair with event counts and first/last timestamps.
system.access.auditOne row = a masked run_by/run_as identity pair x service x action, where the principal that initiated an action (run_by) differs from the identity it executed as (run_as). The column that matters is event_count - how often that specific pair fired in the window.
RequiresSELECT on system.access; Public Preview
Returns no rows ifSchema not enabledNo SELECT privilegePreview not rolled outVerbose audit off
Run-as escalation is the audit fingerprint of one principal executing under another identity's authority — the exact move behind privilege abuse, lateral movement, or a compromised service principal quietly acting as a human owner. Surfacing every run_by <> run_as pair with counts and recency lets an admin separate legitimate job/service-principal execution from unauthorized impersonation before it turns into data exfiltration or unreviewed permission changes. Because the signal is genuinely sparse (identity_metadata is usually NULL for ordinary actions), each row that does appear is worth an explicit look, and an empty result must be verified rather than trusted as clean.
SELECT service_name, action_name,
CASE
WHEN identity_metadata.run_by IS NULL OR identity_metadata.run_by = '__REDACTED__' THEN identity_metadata.run_by
WHEN identity_metadata.run_by LIKE '%@%' THEN concat(substr(identity_metadata.run_by, 1, 2), '****@****')
WHEN identity_metadata.run_by RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN identity_metadata.run_by
ELSE concat(substr(identity_metadata.run_by, 1, 2), '****')
END AS run_by,
CASE
WHEN identity_metadata.run_as IS NULL OR identity_metadata.run_as = '__REDACTED__' THEN identity_metadata.run_as
WHEN identity_metadata.run_as LIKE '%@%' THEN concat(substr(identity_metadata.run_as, 1, 2), '****@****')
WHEN identity_metadata.run_as RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN identity_metadata.run_as
ELSE concat(substr(identity_metadata.run_as, 1, 2), '****')
END AS run_as,
COUNT(*) AS event_count,
MIN(event_time) AS first_event_time, MAX(event_time) AS last_event_time,
-- status: worst-first band on run-by != run-as event volume per identity pair (field heuristic; :warn_runas_events / :crit_runas_events). Zero rows does not mean OK - see caveats.
CASE
WHEN COUNT(*) >= :crit_runas_events THEN 'CRITICAL'
WHEN COUNT(*) >= :warn_runas_events THEN 'WARN'
ELSE 'OK'
END AS status
FROM system.access.audit
WHERE identity_metadata.run_by IS NOT NULL
AND identity_metadata.run_as IS NOT NULL
AND identity_metadata.run_by <> identity_metadata.run_as
AND event_date >= current_date() - INTERVAL :period_days DAYS
AND event_date < current_date()
GROUP BY 1, 2, identity_metadata.run_by, identity_metadata.run_as
ORDER BY event_count DESC:period_daysdefault 30rolling window in days:warn_runas_eventsdefault 5run_by != run_as events for one identity pair in the window that flags WARN:crit_runas_eventsdefault 25that flags CRITICAL| Column | How to read it |
|---|---|
| service_name | The audit service that logged the events (e.g. unityCatalog, jobs, accountsAccessControl). Grouping dimension; tells you which surface the run-as activity occurred on. |
| action_name | The specific operation performed. Treat as a free-form label grouped, not a filtered enum; the same run_by/run_as pair may span multiple actions. |
| run_by | The principal that actually initiated the action, masked (email -> first-2-chars + '****@****' e.g. 'da****@****'; other name -> first-2-chars + '****' e.g. 'op****'; UUID / '__REDACTED__' passed through unchanged). This is the 'who triggered it'. |
| run_as | The identity the action executed AS, masked by the same CASE logic as run_by. run_by <> run_as by construction (WHERE clause), so this row always represents one identity acting as another. |
| event_count | COUNT(*) of audited events for this (service, action, run_by, run_as) combination in the window. Higher = more repeated escalation activity; a single one-off may be routine, a sustained high count warrants a look. |
| first_event_time | MIN(event_time) for this pair in the window. Shows when the run-as relationship first appeared. |
| last_event_time | MAX(event_time) for this pair. Recency signal; a last_event_time near the window end means the pattern is still active. |
status = OK; event_count below :warn_runas_events for one run_by/run_as pair - field heuristic; a low, steady count is often a legitimate job running as a service principal.
status = WARN at/above :warn_runas_events, CRITICAL at/above :crit_runas_events - field heuristic; also treat a ZERO-ROW result with suspicion rather than relief - see caveats.
| service_name | action_name | run_by | run_as | event_count | first_event_time | last_event_time |
|---|---|---|---|---|---|---|
| jobs | runNow | da****@**** | sv****@**** | 42 | 2026-06-08 02:15:03 | 2026-07-05 02:15:11 |
| unityCatalog | getTable | ad****@**** | a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d | 7 | 2026-06-19 14:02:44 | 2026-07-04 09:31:20 |
| accountsAccessControl | updatePermissions | op**** | __REDACTED__ | 1 | 2026-06-28 11:47:02 | 2026-06-28 11:47:02 |
Triage each masked run_by → run_as pair: confirm the run_by principal is authorized to execute as the run_as identity (expected for job/service-principal ownership) and investigate any pair that is not — starting with the highest event_count and most recent last_event_time. Because the signal is sparse, if zero rows return, verify the audit table is actually populated before concluding "no escalation."
Feeds into: run-as escalation
A 90-day rollup of account, access-control, and Unity Catalog audit events grouped by service, action, and acting principal, showing how often each fired, from how many distinct source IPs, and the first/last time it was seen.
system.access.auditOne row = an actor (masked identity) x service x action combo in the window. The columns that matter are event_count (how often they did it) and distinct_source_ips (how many different locations it came from).
RequiresSELECT on system.access; Public Preview
Returns no rows ifSchema not enabledNo SELECT privilegePreview not rolled out
Grant and admin-role changes are the highest-blast-radius actions in the account: one `updatePermissions` or admin grant can silently hand a principal access to sensitive data or the ability to alter other people's access. This gives an admin a defensible, time-bounded record of who altered permissions, how often, and from how many distinct network locations, so anomalous bursts or logins from unexpected IPs surface for review. It is the activity-history counterpart to the current-state grant inventory: the inventory tells you what access exists today, this tells you who moved it and when. Because it groups by the raw `action_name`, it captures the full spread of change actions rather than a hardcoded subset that could miss a renamed or newly added operation.
SELECT service_name, action_name,
CASE
WHEN actor IS NULL OR actor = '__REDACTED__' THEN actor
WHEN actor LIKE '%@%' THEN concat(substr(actor, 1, 2), '****@****')
WHEN actor RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN actor
ELSE concat(substr(actor, 1, 2), '****')
END AS actor,
event_count, distinct_source_ips, first_event_time, last_event_time,
-- status: worst-first band on admin/role-change event volume per actor+action (field heuristic; :warn_admin_events / :crit_admin_events).
CASE
WHEN event_count >= :crit_admin_events THEN 'CRITICAL'
WHEN event_count >= :warn_admin_events THEN 'WARN'
ELSE 'OK'
END AS status
FROM (
SELECT service_name, action_name,
COALESCE(user_identity.email, user_identity.subject_name) AS actor,
COUNT(*) AS event_count,
COUNT(DISTINCT source_ip_address) AS distinct_source_ips,
MIN(event_time) AS first_event_time, MAX(event_time) AS last_event_time
FROM system.access.audit
WHERE service_name IN ('accounts','accountsAccessControl','unityCatalog')
AND event_date >= current_date() - INTERVAL :period_days DAYS
AND event_date < current_date()
GROUP BY 1, 2, 3
)
ORDER BY event_count DESC:period_daysdefault 30rolling window in days:warn_admin_eventsdefault 20admin/role-change events by one actor+action in the window that flags WARN:crit_admin_eventsdefault 100that flags CRITICAL| Column | How to read it |
|---|---|
| service_name | The audit service that emitted the event — one of accounts, accountsAccessControl, or unityCatalog. Tells you which control surface the change touched. |
| action_name | The specific operation recorded (e.g. an update-permissions or set-admin action). Treat this as representative, not an exhaustive enum — read whatever values appear; do not assume a fixed list. |
| actor | The acting principal (email, else subject_name), masked: emails shown as first-two-chars + ****@****, other names as first-two-chars + ****, UUID principals and __REDACTED__ passed through as-is. May be NULL when the audit event carried no identity. |
| event_count | Number of matching events for this service/action/actor over the window. A count, not a rate — a high value is a burst worth explaining. |
| distinct_source_ips | How many distinct source IP addresses this actor used for this action. A jump here (many IPs for one principal) is an anomaly signal. |
| first_event_time | Timestamp of the earliest event in the window for this row — when this activity started being seen. |
| last_event_time | Timestamp of the most recent event for this row — how fresh the activity is; near-now with a high count means it is still happening. |
status = OK; event_count below :warn_admin_events per actor/action (field heuristic - tune :warn_admin_events for your account).
status = WARN at/above :warn_admin_events, CRITICAL at/above :crit_admin_events - field heuristic; also worth a look whenever distinct_source_ips is unusually high for one actor.
| service_name | action_name | actor | event_count | distinct_source_ips | first_event_time | last_event_time |
|---|---|---|---|---|---|---|
| accountsAccessControl | updatePermissions | da****@**** | 47 | 3 | 2026-04-09 08:12:31 | 2026-07-05 17:44:02 |
| unityCatalog | setAdmin | 3f2504e0-4f89-41d3-9a0c-0305e82c3301 | 2 | 1 | 2026-06-28 11:03:55 | 2026-06-30 09:21:10 |
| accounts | createServicePrincipal | ad**** | 1 | 1 | 2026-05-22 14:57:08 | 2026-05-22 14:57:08 |
Sort by event_count and distinct_source_ips to triage: for any principal with an unexpected burst of permission/admin actions or activity from an unusual number of source IPs, pull the matching events in system.access.audit and confirm the change was authorized; then reconcile the resulting access against the current-state grant inventory to ensure nothing was left over-privileged.
Feeds into: admin/role hygiene; access history
A current-state list of HIGH-confidence auto-classified (PII/sensitive) columns, each showing whether a column mask is applied and flagging the ones that are left unmasked.
system.data_classification.resultssystem.information_schema.column_masksOne row = a HIGH-confidence classified (PII/sensitive) column and whether a column mask covers it. The column that matters is is_unmasked - true means the auto-classifier is confident the column is sensitive and no masking function was found on it.
RequiresSELECT on system.data_classification and system.information_schema; system.data_classification is Public Preview and requires Unity Catalog; column_masks is Public Preview (DBR 12.2 LTS+)
Returns no rows ifSchema not enabledNo SELECT privilegePreview not rolled outNo serverless computeABAC policies only
This is the headline data-governance gap: columns the classifier is highly confident hold PII or sensitive data, yet no column mask is protecting them, so that data is being returned in the clear to anyone with `SELECT`. Each unmasked row is a candidate exposure that can drive a privacy incident, a compliance finding (GDPR/CCPA/HIPAA), or an audit deficiency. Closing these gaps is cheap relative to the cost of a breach or regulatory penalty — but only if you first confirm the miss is a real gap and not a visibility blind spot of the collecting principal.
SELECT dc.catalog_name, dc.schema_name, dc.table_name, dc.column_name, dc.class_tag, dc.confidence,
cm.MASK_NAME,
CASE WHEN cm.MASK_NAME IS NULL THEN true ELSE false END AS is_unmasked,
-- status: yes/no risk - a HIGH-confidence classified column with no mask applied.
CASE WHEN cm.MASK_NAME IS NULL THEN 'CRITICAL' ELSE 'OK' END AS status
FROM system.data_classification.results dc
LEFT JOIN system.information_schema.column_masks cm
ON cm.CATALOG_NAME = dc.catalog_name
AND cm.SCHEMA_NAME = dc.schema_name
AND cm.TABLE_NAME = dc.table_name
AND cm.COLUMN_NAME = dc.column_name
WHERE dc.confidence = 'HIGH'
AND dc.class_tag IS NOT NULL
ORDER BY is_unmasked DESC, dc.catalog_name, dc.schema_name, dc.table_name, dc.column_namecatalog_name / schema_name / table_name / column_name and its detected class_tag.is_unmasked — true when no mask is present, giving you the classified-but-unprotected shortlist.| Column | How to read it |
|---|---|
| catalog_name | Unity Catalog catalog containing the classified column. |
| schema_name | Schema (database) within that catalog. |
| table_name | Table or view holding the column. |
| column_name | The specific column the classifier flagged as sensitive. |
| class_tag | The detected sensitivity class (e.g. the kind of PII) the scanner assigned to this column. |
| confidence | Scanner confidence for the detection — always `HIGH` here, since lower-confidence rows are filtered out. |
| MASK_NAME | Name of the column mask protecting this column, or NULL/blank if no mask row was visible to the collector. |
| is_unmasked | true = classified sensitive but no mask found (the exposure to review); false = a mask is applied. Note a 'true' can also mean the mask table wasn't visible to the collecting principal, so treat it as a candidate, not a verdict. |
status = OK; is_unmasked = false (a mask is applied) - field heuristic; confirm by reviewing MASK_NAME per column.
status = CRITICAL when is_unmasked = true - field heuristic; prioritize columns with the widest access (cross-check against access_grants_inventory).
| catalog_name | schema_name | table_name | column_name | class_tag | MASK_NAME | is_unmasked |
|---|---|---|---|---|---|---|
| prod | customers | accounts | email_address | NULL | true | |
| prod | customers | accounts | ssn | us_ssn | mask_ssn_default | false |
| analytics | hr | employees | home_phone | phone_number | NULL | true |
Work the `is_unmasked = true` rows: for each HIGH-confidence classified column with no mask, decide whether to apply a column mask (or confirm the collector SP simply couldn't see an existing mask), then re-run to confirm the gap closes.
Feeds into: classified-but-unmasked; masking inventory
A 90-day rollup of observed column-to-column data flows, one row per source-column to target-column edge (also split by entity_type and direct_access), showing how many times each edge fired, how many distinct principals drove it, and when it last happened.
system.access.column_lineageOne row = a source column x target column data-flow edge (plus entity_type and direct_access) in the window. The columns that matter are event_count (how often that flow ran) and distinct_principals - how many different identities drove it. This is per-edge, not a table-level rollup; pair it with access_table_lineage_blast_radius for the coarser view.
RequiresSELECT on system.access; GA
Returns no rows ifSchema not enabledLineage not inferable
When a column is classified PII or is otherwise sensitive, the real exposure is not just the column itself but every downstream column it flows into — each copy is a place the guardrail (mask, tag, row filter) may not have followed. This query is the reach map that turns "this column is sensitive" into "here is every target column it propagated to, how heavily, and by how many people," so you can size the blast radius before a schema change or a breach review and prioritize which unmasked copies to remediate first. Because it is the reach side of the classified-but-unmasked analysis, an underestimate here quietly understates real sensitive-data sprawl.
SELECT source_table_full_name, source_column_name, target_table_full_name, target_column_name,
entity_type, direct_access,
COUNT(*) AS event_count,
COUNT(DISTINCT created_by) AS distinct_principals,
MAX(event_time) AS last_event_time,
-- status: worst-first band on how many distinct principals drove this column-to-column edge (field heuristic; :warn_reach_principals / :crit_reach_principals).
CASE
WHEN COUNT(DISTINCT created_by) >= :crit_reach_principals THEN 'CRITICAL'
WHEN COUNT(DISTINCT created_by) >= :warn_reach_principals THEN 'WARN'
ELSE 'OK'
END AS status
FROM system.access.column_lineage
WHERE source_table_full_name IS NOT NULL
AND event_date >= current_date() - INTERVAL :period_days DAYS
AND event_date < current_date()
GROUP BY 1, 2, 3, 4, 5, 6
ORDER BY distinct_principals DESC:period_daysdefault 30rolling window in days:warn_reach_principalsdefault 10distinct principals driving one source-target column edge that flags WARN:crit_reach_principalsdefault 50that flags CRITICAL| Column | How to read it |
|---|---|
| source_table_full_name | Fully-qualified catalog.schema.table of the upstream table the data flowed from. External/path references may appear as a cloud path string rather than a table name. |
| source_column_name | The upstream column that is the origin of this flow — the one whose sensitivity you are tracing. |
| target_table_full_name | Fully-qualified catalog.schema.table of the downstream table the data landed in. |
| target_column_name | The downstream column that received data from the source column — a candidate spot where the source's mask/tag may not have carried over. |
| entity_type | The kind of entity that produced the lineage edge (e.g. the type of workload/statement UC observed). |
| direct_access | true = a direct read/write between the columns; false = an indirect flow such as view expansion. Use to separate first-hop copies from derived/indirect propagation. |
| event_count | COUNT(*) of observed events for this exact edge in the window — a rough intensity/frequency signal, not a row or byte count. |
| distinct_principals | COUNT(DISTINCT created_by) — the number of distinct identities that drove this edge; how broadly the flow is used, and thus how many people to potentially follow up with. |
| last_event_time | MAX(event_time) — the most recent timestamp this edge was observed; how fresh the propagation is (stale edges may be legacy flows). |
status = OK; distinct_principals below :warn_reach_principals for one source-target column edge - field heuristic.
status = WARN at/above :warn_reach_principals, CRITICAL at/above :crit_reach_principals - field heuristic; cross-check the source column against access_data_classification_inventory before prioritizing, since this query itself carries no sensitivity signal.
| source_table_full_name | source_column_name | target_table_full_name | target_column_name | direct_access | event_count | distinct_principals | last_event_time |
|---|---|---|---|---|---|---|---|
| prod.crm.customers | prod.analytics.marketing_leads | true | 482 | 6 | 2026-07-05 22:14:03 | ||
| prod.crm.customers | ssn | prod.sandbox.dev_copy | ssn_raw | true | 57 | 3 | 2026-07-04 09:41:27 |
| prod.crm.customers | prod.reporting.exec_dashboard_vw | contact_email | false | 1203 | 11 | 2026-07-05 06:02:55 |
Pick your HIGH-confidence classified / sensitive source columns, look up their edges here to enumerate every target column they reach, then confirm each target carries the same mask/tag/row-filter — remediate the unmasked copies (highest event_count x distinct_principals first) and use the map as the impact list before altering or dropping the source table.
Feeds into: column-lineage blast radius; classified-but-unmasked (reach side); access history
A current-state inventory listing every column in the metastore that has a column mask applied, naming the mask function and the columns it reads.
system.information_schema.column_masksOne row = a column in the metastore that has a column mask attached. The columns that matter are mask_name (which masking function) and using_columns (what it reads to decide the mask).
RequiresSELECT on system.information_schema; Public Preview, DBR 12.2 LTS+, Unity Catalog required
Returns no rows ifNo SELECT privilegeABAC policies only
Column masks are the enforcement layer that keeps SSNs, emails, and card numbers from rendering in the clear to unprivileged readers, so knowing exactly which columns carry a mask is the difference between a provable control and an assumption. This inventory is the ground truth you diff against your data-classification results to find sensitive columns that were never masked, or against change history to catch masks that were dropped in a refactor. Because the view is privilege-scoped, a short list can mean either genuinely thin coverage or simply that the collector could not see certain tables, and mistaking the latter for a clean bill of health is how leaks slip through. Treat this as one half of a masking-coverage audit, not a standalone all-clear.
-- column_masks lists only manually-applied (ALTER ... SET MASK) masks; ABAC tag-based policy masks never appear here, so an ABAC-governed account can look falsely unmasked - enumerate those via the UC REST API.
SELECT CATALOG_NAME AS table_catalog,
SCHEMA_NAME AS table_schema,
TABLE_NAME AS table_name,
COLUMN_NAME AS column_name,
MASK_NAME AS mask_name,
MASK_COL_USAGE AS using_columns
FROM system.information_schema.column_masks
ORDER BY table_catalog, table_schema, table_name, column_name| Column | How to read it |
|---|---|
| table_catalog | Catalog of the table holding the masked column; part of the three-level name that uniquely identifies the object. |
| table_schema | Schema (database) of the table holding the masked column. |
| table_name | Table (or view) whose column is masked. |
| column_name | The specific column that has a mask applied — the sensitive field being protected. |
| mask_name | Name of the UDF/mask function bound to this column; identifies the masking logic in effect. Verify it is the intended function, not a stale or no-op mask. |
| using_columns | Additional columns the mask function reads as inputs (for example a role or region column it branches on). Empty/NULL means the mask reads only the masked column itself. |
Inventory / reference query — it returns state to read, not a pass/fail band.
| table_catalog | table_schema | table_name | column_name | mask_name | using_columns |
|---|---|---|---|---|---|
| prod | hr | employees | ssn | main.security.mask_ssn | NULL |
| prod | finance | payments | card_number | main.security.mask_pan | region |
| analytics | crm | customers | main.security.mask_email_by_group | is_pii_admin |
Diff this masked-column list against your HIGH-confidence classified columns (via access_classified_unmasked) to surface sensitive fields with no mask, and spot-check that each mask_name is the intended function rather than a stale or no-op one — then remediate the gaps by applying or fixing masks. Because the view is privilege-scoped, confirm the collector could actually see the tables in question before declaring any table 'unmasked'.
Feeds into: masking/row-filters inventory; classified-but-unmasked
A deduplicated inventory of every column the auto-classifier flagged as sensitive, one row per catalog/schema/table/column/class-tag/confidence/data-type, carrying the peak match frequency and first/last detection times.
system.data_classification.resultsOne row = a column the auto-classifier tagged, deduplicated to one row per catalog/schema/table/column/class_tag/confidence/data_type. The columns that matter are class_tag (what kind of sensitive data) and max_frequency (how consistently it matched).
RequiresSELECT on system.data_classification; Public Preview, Unity Catalog required, needs the data-classification feature AND the system.data_classification schema both enabled
Returns no rows ifSchema not enabledPreview not rolled outNo serverless compute
This is your ground-truth map of where the scanner believes PII and other sensitive data physically sits, which is the starting point for every masking, row-filter, and least-privilege decision you make. Without it you are guessing which columns need protection, and an unprotected email/SSN/card column is both a breach-exposure and a compliance-fine liability. Because it covers enabled catalogs only and omits unclassified columns, treat it as a floor on your sensitive-data surface, never a complete census. It pairs directly with the mask inventory to surface the headline gap: classified-but-unmasked columns.
SELECT catalog_name, schema_name, table_name, column_name, class_tag, confidence, data_type,
MAX(frequency) AS max_frequency,
MAX(latest_detected_time) AS latest_detected_time,
MIN(first_detected_time) AS first_detected_time
FROM system.data_classification.results
WHERE class_tag IS NOT NULL
GROUP BY 1, 2, 3, 4, 5, 6, 7
ORDER BY catalog_name, schema_name, table_name, column_nameEMAIL, US_SSN).HIGH vs LOW) and the strongest match rate seen for that column, so you can triage firm hits from weak ones.| Column | How to read it |
|---|---|
| catalog_name | Unity Catalog catalog containing the classified column (enabled catalogs only). |
| schema_name | Schema within that catalog. |
| table_name | Table or view holding the column. |
| column_name | The specific column the scanner classified. |
| class_tag | The detected sensitivity class, e.g. EMAIL, US_SSN, CREDIT_CARD_NUMBER. Rows with a NULL tag are excluded. |
| confidence | Scanner confidence for this class on this column: HIGH or LOW (part of the row grain). Prioritise HIGH for remediation. |
| data_type | Declared column data type (e.g. STRING, INT) at detection time; also part of the row grain. |
| max_frequency | Highest observed frequency (float 0–1) = the largest share of sampled values that matched this class. Closer to 1 means the whole column looks like this class. |
| latest_detected_time | Most recent time this class was detected on the column — how fresh the finding is. |
| first_detected_time | Earliest time this class was detected — roughly when the sensitive data first appeared/was first seen by the scanner. |
Inventory / reference query — it returns state to read, not a pass/fail band.
| catalog_name | schema_name | table_name | column_name | class_tag | confidence | max_frequency |
|---|---|---|---|---|---|---|
| prod | crm | customers | email_address | HIGH | 0.98 | |
| prod | crm | customers | ssn | US_SSN | HIGH | 0.91 |
| analytics | staging | raw_events | note_text | LOW | 0.07 |
Sort by confidence=HIGH and high max_frequency, then cross-check each column against the column-mask and row-filter inventories (or the access_classified_unmasked query) and apply a mask/filter to any HIGH-confidence sensitive column that has none.
Feeds into: classification; classified-but-unmasked (classification side)
Every MANAGED or EXTERNAL table in your account that never showed up as a read (lineage source) during the look-back window, listed with its owner and staleness age as cleanup candidates to investigate.
system.access.table_lineageOne row = a MANAGED or EXTERNAL table that never appeared as a lineage source (was never read) in the window. The columns that matter are days_since_altered (staleness) and table_owner (who to ask before dropping anything).
RequiresSELECT on system.access and system.information_schema; system.access.table_lineage is GA; information_schema requires Unity Catalog
Returns no rows ifNo SELECT privilegeLineage not inferableOutside retention window
Stale MANAGED and EXTERNAL tables quietly accrue storage cost and expand the governance and breach surface long after anyone stops reading them, yet no one wants to be the person who drops a table that turns out to feed a quarterly report. This query narrows the whole account down to a focused shortlist of tables with no observed reads, each paired with an owner to ask and an age to prioritize. Critically, absence from lineage is a candidate signal, not proof of disuse, so it prevents accidental drops by forcing a cross-check rather than handing you a delete list. The payoff is targeted storage reclamation and a smaller attack surface, with the owner already identified for follow-up.
WITH lineage_window AS (
SELECT *
FROM system.access.table_lineage
WHERE event_date >= dateadd(day, -:period_days, current_date())
AND event_date < current_date()
),
-- Every table that appeared as a lineage SOURCE in the window (any direct_access
-- value - an indirect/view-mediated read of a base table still proves it was read,
-- so we must NOT exclude direct_access=false here or we over-flag base tables).
-- Prefer the discrete *_catalog/_schema/_name columns over CONCAT vs _full_name,
-- because _full_name can be backtick-quoted for reserved-word identifiers.
source_tables AS (
SELECT DISTINCT
source_table_catalog AS catalog,
source_table_schema AS schema,
source_table_name AS name
FROM lineage_window
WHERE source_table_name IS NOT NULL
),
-- The managed/external tables this account holds (privilege-scoped inventory).
inventory AS (
SELECT table_catalog,
table_schema,
table_name,
table_type,
table_owner,
created,
last_altered
FROM system.information_schema.tables
WHERE table_catalog <> 'system'
AND table_schema <> 'information_schema'
AND table_type IN ('MANAGED', 'EXTERNAL')
)
SELECT inv.table_catalog,
inv.table_schema,
inv.table_name,
inv.table_type,
CASE
WHEN inv.table_owner IS NULL OR inv.table_owner = '__REDACTED__' THEN inv.table_owner
WHEN inv.table_owner LIKE '%@%' THEN concat(substr(inv.table_owner, 1, 2), '****@****')
WHEN inv.table_owner RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN inv.table_owner
ELSE concat(substr(inv.table_owner, 1, 2), '****')
END AS table_owner,
inv.created,
inv.last_altered,
-- Age in whole days since the object was last altered (NULL -> age unknown).
datediff(current_date(), DATE(inv.last_altered)) AS days_since_altered,
-- status: worst-first band on staleness (field heuristic; :warn_dead_days / :crit_dead_days). NULL age -> NOT_ASSESSED, never a finding.
CASE
WHEN datediff(current_date(), DATE(inv.last_altered)) IS NULL THEN 'NOT_ASSESSED'
WHEN datediff(current_date(), DATE(inv.last_altered)) >= :crit_dead_days THEN 'CRITICAL'
WHEN datediff(current_date(), DATE(inv.last_altered)) >= :warn_dead_days THEN 'WARN'
ELSE 'OK'
END AS status
FROM inventory inv
LEFT JOIN source_tables src
ON inv.table_catalog = src.catalog
AND inv.table_schema = src.schema
AND inv.table_name = src.name
WHERE src.name IS NULL -- never appeared as a lineage source in the window
ORDER BY days_since_altered DESC NULLS LAST:period_daysdefault 30rolling window in days:warn_dead_daysdefault 90days since last altered for a table with no lineage-source hits that flags WARN:crit_dead_daysdefault 365that flags CRITICAL:period_days window (excluding today).system catalog and information_schema, so only your own governed data objects appear.created / last_altered timestamps, and a days_since_altered staleness age.| Column | How to read it |
|---|---|
| table_catalog | Catalog of the candidate table (system catalog excluded by design). |
| table_schema | Schema of the candidate table (information_schema excluded). |
| table_name | Name of the MANAGED/EXTERNAL table that never appeared as a lineage source in the window. |
| table_type | Either MANAGED or EXTERNAL; the only two object types considered here (views/other types are not candidates). |
| table_owner | Masked owning principal to follow up with; emails show first two chars then '****@****', other names first two chars then '****', UUIDs and '__REDACTED__' shown as-is, NULL if unknown. |
| created | When the object was created (may be NULL/late-populated on some object types). |
| last_altered | When the object was last altered per the catalog (NULL means age unknown, not new). |
| days_since_altered | Whole days from last_altered to today, i.e. how stale the object looks; NULL when last_altered is NULL (age unknown). Higher = staler, but not proof of disuse. |
status = OK; days_since_altered below :warn_dead_days for a table that never appeared as a lineage source - field heuristic; a candidate can still be OK for a while (e.g. quarterly jobs).
status = WARN at/above :warn_dead_days, CRITICAL at/above :crit_dead_days, or NOT_ASSESSED when last_altered is NULL (age unknown - do not treat as a finding) - field heuristic; widen :period_days first if your account's retention allows it, since a short lineage window over-flags quarterly/long-tail tables.
| table_catalog | table_schema | table_name | table_type | table_owner | days_since_altered |
|---|---|---|---|---|---|
| prod_analytics | marketing | campaign_snapshot_2023 | MANAGED | da****@**** | 612 |
| prod_raw | ingest | legacy_clickstream_ext | EXTERNAL | a1b2c3d4-1234-5678-9abc-def012345678 | 418 |
| sandbox | scratch | tmp_join_backup | MANAGED | sv**** | NULL |
Treat the list as cleanup candidates only: for each table, cross-check whether it was actually read via `system.access.audit`, `query.history`, and `system.storage.table_metrics_history` (and widen `:period_days` toward your workspace's `system.access.*` retention) before ever proposing a DROP or archive to the owner shown.
Feeds into: dead-table / cleanup-candidate detection (gov-1); owner + age; cross-check inputs
A current-state, privilege-scoped inventory of table- and catalog-level grants, rolled up to one row per object scope, privilege type, and grantee with the number of grants and distinct objects each covers.
system.information_schema.table_privilegesOne row = an object scope (TABLE or CATALOG) x privilege type x grantee, rolled up. The columns that matter are grant_count (how many individual grants) and distinct_objects (how many different tables/catalogs that grantee touches).
RequiresSELECT on system.information_schema; Unity Catalog required
Returns no rows ifNo SELECT privilege
This is the foundational least-privilege review artifact: it shows, per principal, how many objects each privilege actually reaches, so you can spot over-broad access — a single grantee holding `SELECT` on hundreds of tables, or `ALL PRIVILEGES` at catalog scope — before it becomes an exfiltration or blast-radius risk. Concentrated or wildcard grants are exactly the audit findings that fail SOC 2 / access-recertification and widen the surface for a compromised credential. Because `information_schema` is privilege-aware, treat this as a floor on exposure, not a complete grant graph — anything it surfaces is real, but it can miss grants the collector cannot see.
SELECT object_scope, PRIVILEGE_TYPE,
CASE
WHEN GRANTEE IS NULL OR GRANTEE = '__REDACTED__' THEN GRANTEE
WHEN GRANTEE LIKE '%@%' THEN concat(substr(GRANTEE, 1, 2), '****@****')
WHEN GRANTEE RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN GRANTEE
ELSE concat(substr(GRANTEE, 1, 2), '****')
END AS GRANTEE,
grant_count, distinct_objects
FROM (
SELECT 'TABLE' AS object_scope, PRIVILEGE_TYPE, GRANTEE,
COUNT(*) AS grant_count,
COUNT(DISTINCT TABLE_CATALOG || '.' || TABLE_SCHEMA || '.' || TABLE_NAME) AS distinct_objects
FROM system.information_schema.table_privileges
GROUP BY 1, 2, 3
UNION ALL
SELECT 'CATALOG' AS object_scope, PRIVILEGE_TYPE, GRANTEE,
COUNT(*) AS grant_count,
COUNT(DISTINCT CATALOG_NAME) AS distinct_objects
FROM system.information_schema.catalog_privileges
GROUP BY 1, 2, 3
)
ORDER BY object_scope, PRIVILEGE_TYPE, GRANTEETABLE and CATALOG grants — combined into one list.grant_count) and how many distinct objects that spans (distinct_objects), so you see reach at a glance.SHOW GRANTS.| Column | How to read it |
|---|---|
| object_scope | The level the grant applies at — either `TABLE` (table-level grants) or `CATALOG` (catalog-level grants). Tells you the breadth of the object the privilege is attached to. |
| PRIVILEGE_TYPE | The privilege granted, e.g. `SELECT`, `MODIFY`, `USE CATALOG`, `ALL PRIVILEGES`. Watch for broad or wildcard privileges held widely. |
| GRANTEE | The principal (user, group, or service principal) holding the grant, masked for safe sharing. Emails show as `da****@****`, other names truncated to `xx****`, UUID service principals and `__REDACTED__` (and NULL) left intact. |
| grant_count | How many individual grant rows this grantee holds for this privilege at this scope — a raw COUNT(*). A large number signals broad reach for one principal. Normally equals `distinct_objects` since the source grain is one row per object. |
| distinct_objects | How many distinct objects (fully-qualified tables, or catalogs) that privilege spans for this grantee — the blast radius. Compare against `grant_count` to gauge concentration. |
Inventory / reference query — it returns state to read, not a pass/fail band.
| object_scope | PRIVILEGE_TYPE | GRANTEE | grant_count | distinct_objects |
|---|---|---|---|---|
| TABLE | SELECT | da****@**** | 397 | 397 |
| CATALOG | USE CATALOG | da**** | 6 | 6 |
| TABLE | ALL PRIVILEGES | a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d | 58 | 58 |
Use this as your least-privilege baseline: scan for grantees or privilege types with a high grant_count or broad distinct_objects (especially wildcard privileges like ALL PRIVILEGES or MODIFY held broadly), flag over-broad access for review, and cross-check against the full SHOW GRANTS output since this inventory is privilege-scoped and incomplete.
Feeds into: admin/role hygiene; access history (grant state)
A privilege map counting who holds which grants on schemas, UC connections, credentials, and external locations, one row per object scope, privilege, and grantee.
system.information_schema.One row = an object scope (SCHEMA, CONNECTION, CREDENTIAL, or EXTERNAL_LOCATION) x privilege type x grantee, with a grant count. Use it to see who holds broad integration-level access beyond plain table/catalog grants.
RequiresSELECT on system.information_schema; Unity Catalog required
Returns no rows ifNo SELECT privilege
Schemas and integration objects (connections, credentials, external locations) are the common blind spots in a least-privilege review — a broad grant on a credential or external location can hand a principal reach into external systems or raw storage that a table-level audit never sees. This query extends the baseline grant map beyond tables and catalogs so you can spot over-broad or stale grants on exactly the objects that bridge Unity Catalog to the outside world. Because these views are privilege-aware, the result is always a partial floor of the real grant graph, never a complete `SHOW GRANTS`.
-- SAFE as written (uses only GRANTEE/PRIVILEGE_TYPE, shared across all privilege views).
-- NEEDS CONFIRMATION before reading any per-view object-name column: DESCRIBE each sibling view in your own workspace first.
SELECT 'SCHEMA' AS object_scope, PRIVILEGE_TYPE,
CASE
WHEN GRANTEE IS NULL OR GRANTEE = '__REDACTED__' THEN GRANTEE
WHEN GRANTEE LIKE '%@%' THEN concat(substr(GRANTEE, 1, 2), '****@****')
WHEN GRANTEE RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN GRANTEE
ELSE concat(substr(GRANTEE, 1, 2), '****')
END AS GRANTEE,
COUNT(*) AS grant_count
FROM system.information_schema.schema_privileges GROUP BY 1,2,GRANTEE
UNION ALL
SELECT 'CONNECTION', PRIVILEGE_TYPE,
CASE
WHEN GRANTEE IS NULL OR GRANTEE = '__REDACTED__' THEN GRANTEE
WHEN GRANTEE LIKE '%@%' THEN concat(substr(GRANTEE, 1, 2), '****@****')
WHEN GRANTEE RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN GRANTEE
ELSE concat(substr(GRANTEE, 1, 2), '****')
END,
COUNT(*)
FROM system.information_schema.connection_privileges GROUP BY 1,2,GRANTEE
UNION ALL
SELECT 'CREDENTIAL', PRIVILEGE_TYPE,
CASE
WHEN GRANTEE IS NULL OR GRANTEE = '__REDACTED__' THEN GRANTEE
WHEN GRANTEE LIKE '%@%' THEN concat(substr(GRANTEE, 1, 2), '****@****')
WHEN GRANTEE RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN GRANTEE
ELSE concat(substr(GRANTEE, 1, 2), '****')
END,
COUNT(*)
FROM system.information_schema.credential_privileges GROUP BY 1,2,GRANTEE
UNION ALL
SELECT 'EXTERNAL_LOCATION', PRIVILEGE_TYPE,
CASE
WHEN GRANTEE IS NULL OR GRANTEE = '__REDACTED__' THEN GRANTEE
WHEN GRANTEE LIKE '%@%' THEN concat(substr(GRANTEE, 1, 2), '****@****')
WHEN GRANTEE RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN GRANTEE
ELSE concat(substr(GRANTEE, 1, 2), '****')
END,
COUNT(*)
FROM system.information_schema.external_location_privileges GROUP BY 1,2,GRANTEE
ORDER BY object_scope, PRIVILEGE_TYPE, GRANTEE__REDACTED__ passed through) so the output is safe to share.| Column | How to read it |
|---|---|
| object_scope | The object family the grant sits on: one of SCHEMA, CONNECTION, CREDENTIAL, or EXTERNAL_LOCATION. This is a literal label, not a per-object name. |
| PRIVILEGE_TYPE | The privilege granted (e.g. USE_SCHEMA, USE_CONNECTION, CREATE_EXTERNAL_TABLE, READ_FILES). Group by it; do not assume a fixed enum. |
| GRANTEE | The principal holding the grant, identity-masked: emails become 'da****@****', other names become 'da****', and UUIDs / '__REDACTED__' are left intact. NULL passes through as NULL. |
| grant_count | How many grant rows (grantee × privilege × object within this scope) matched — effectively the number of objects in that scope on which this grantee holds this privilege. Integer count, and a floor because the view is privilege-aware. |
Inventory / reference query — it returns state to read, not a pass/fail band.
| object_scope | PRIVILEGE_TYPE | GRANTEE | grant_count |
|---|---|---|---|
| SCHEMA | USE_SCHEMA | da****@**** | 14 |
| CREDENTIAL | ACCESS | b3f1c2a9-4d5e-4f6a-8b7c-9d0e1f2a3b4c | 2 |
| EXTERNAL_LOCATION | READ_FILES | da**** | 1 |
Scan the high-count and high-privilege rows on CREDENTIAL and EXTERNAL_LOCATION scopes first — these bridge UC to external storage and systems — and open a least-privilege review for any grantee holding broad or unexpected grants, then confirm against a live SHOW GRANTS since these counts are a privilege-scoped floor.
Feeds into: admin/role hygiene; integrations (connections/credentials/external locations)
A 30-day rollup of account authentication activity, one row per masked principal x source IP x service x action, with success and non-success event counts and a first/last-seen window.
system.access.auditOne row = a masked principal x source IP x service x action combo in the window. The columns that matter are non_success_count (failed/non-200 attempts) and distinct_source_ips (how many locations that principal authenticated from).
RequiresSELECT on system.access; Public Preview
Returns no rows ifSchema not enabledNo SELECT privilegePreview not rolled out
Account logins are the front door to your Databricks estate, and a compromised credential is the cheapest way for an attacker to get in. This rollup lets you spot the tells of credential stuffing or a stolen token: a burst of non-success events against a single account, one principal showing up across an unusual number of source IPs (visible as many rows for the same masked principal), or activity that doesn't match a human's normal pattern. Because it breaks activity out by action_name, it shows the mix of MFA-based, token-based, and other login paths so you can confirm strong-auth is actually being used. Catching this early is the difference between a blocked login attempt and a full account takeover.
SELECT CASE
WHEN COALESCE(user_identity.email, user_identity.subject_name) IS NULL OR COALESCE(user_identity.email, user_identity.subject_name) = '__REDACTED__' THEN COALESCE(user_identity.email, user_identity.subject_name)
WHEN COALESCE(user_identity.email, user_identity.subject_name) LIKE '%@%' THEN concat(substr(COALESCE(user_identity.email, user_identity.subject_name), 1, 2), '****@****')
WHEN COALESCE(user_identity.email, user_identity.subject_name) RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN COALESCE(user_identity.email, user_identity.subject_name)
ELSE concat(substr(COALESCE(user_identity.email, user_identity.subject_name), 1, 2), '****')
END AS principal,
source_ip_address, service_name, action_name,
COUNT(*) AS event_count,
SUM(CASE WHEN response.status_code = 200 THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN response.status_code <> 200 OR response.status_code IS NULL THEN 1 ELSE 0 END) AS non_success_count,
COUNT(DISTINCT source_ip_address) AS distinct_source_ips,
MIN(event_time) AS first_event_time, MAX(event_time) AS last_event_time,
-- status: worst-first band on non-success (failed) auth attempts per principal+IP+action (field heuristic; :warn_failed_logins / :crit_failed_logins).
CASE
WHEN SUM(CASE WHEN response.status_code <> 200 OR response.status_code IS NULL THEN 1 ELSE 0 END) >= :crit_failed_logins THEN 'CRITICAL'
WHEN SUM(CASE WHEN response.status_code <> 200 OR response.status_code IS NULL THEN 1 ELSE 0 END) >= :warn_failed_logins THEN 'WARN'
ELSE 'OK'
END AS status
FROM system.access.audit
WHERE service_name = 'accounts'
AND event_date >= current_date() - INTERVAL :period_days DAYS
AND event_date < current_date()
GROUP BY COALESCE(user_identity.email, user_identity.subject_name), 2, 3, 4
ORDER BY non_success_count DESC:period_daysdefault 30rolling window in days:warn_failed_loginsdefault 5non-success auth events for one principal+source_ip+service+action combo that flags WARN:crit_failed_loginsdefault 20that flags CRITICALstatus_code = 200) versus non-success or unknown-status attempts.da**@**, other names truncated, UUIDs and __REDACTED__ left intact) so the output is safe to share.| Column | How to read it |
|---|---|
| principal | The acting identity, masked for safe sharing: emails become `da****@****`, other names are truncated to `xx****`, and UUID-form or `__REDACTED__` values pass through unchanged. Sourced from user_identity.email, falling back to subject_name; can be NULL (this field is frequently unpopulated). |
| source_ip_address | The IP the events in this row came from. Each row is one IP, so an identity appearing across many rows (many IPs) — or from an unfamiliar geography — is the anomaly signal. |
| service_name | Always `accounts` here (the query filters to account authentication), confirming these are login/credential events rather than other control-plane actions. |
| action_name | The specific authentication operation (e.g. mfaLogin, tokenLogin, and others). These values are representative, not an exhaustive list — read whatever appears; do not assume a fixed set. |
| event_count | Total audited events for this principal x IP x service x action combination in the window. High values on a single account warrant a look. |
| success_count | How many of those events returned `status_code = 200` (succeeded). |
| non_success_count | How many events had a status other than 200 or a NULL status. A high non_success_count with few successes looks like failed/blocked attempts (possible credential stuffing); the NULL-inclusive form is defensive because the status_code type is unverified. success_count + non_success_count always equals event_count. |
| distinct_source_ips | COUNT(DISTINCT source_ip_address) — but the query already GROUPs BY source_ip_address, so each row covers exactly one IP and this value is effectively always 1. It is NOT a per-principal fan-out count. To measure an identity spreading across many IPs, count how many rows share the same masked principal instead. |
| first_event_time | Earliest event timestamp in the 30-day window for this row — the start of the observed activity span. |
| last_event_time | Latest event timestamp in the window for this row — how recently the activity occurred. |
status = OK; non_success_count below :warn_failed_logins for one principal+IP+action - field heuristic; some non-success events are normal (typos, expired tokens).
status = WARN at/above :warn_failed_logins, CRITICAL at/above :crit_failed_logins - field heuristic; also worth a look whenever distinct_source_ips is high for a single principal, which this query surfaces but does not score.
| principal | source_ip_address | action_name | event_count | success_count | non_success_count | distinct_source_ips |
|---|---|---|---|---|---|---|
| da****@**** | 203.0.113.24 | mfaLogin | 48 | 47 | 1 | 1 |
| da****@**** | 198.51.100.7 | tokenLogin | 1204 | 12 | 1192 | 1 |
| bo****@**** | 192.0.2.55 | mfaLogin | 9 | 9 | 0 | 1 |
Because distinct_source_ips is always 1 at this grain, do not sort on it. Instead sort rows by non_success_count to surface accounts racking up failed/blocked attempts, and separately aggregate rows per masked principal to count how many distinct source IPs each identity spans. Pivot on the masked principal: confirm with the account owner, force a credential/token rotation and MFA re-enrollment if unexplained, and block anomalous source IPs. Also scan the action_name mix to confirm MFA-based login is actually in use rather than weaker paths.
Feeds into: login concentration; MFA & credentials (events-only); access history
A 30-day rollup of inbound (ingress) network-policy enforcement events, grouped by policy outcome, rule, request path, principal, and source IP, with a count and first/last timestamps per group — the query groups by outcome rather than filtering it, so it surfaces whatever outcomes the table records (DENY / DENY_DRY_RUN denials in practice).
system.access.inbound_networkOne row = a policy outcome x rule x request path x principal x source IP combo in the window. The column that matters is denial_count - how many times that combination was denied at the network edge.
RequiresSELECT on system.access; Public Preview
Returns no rows ifSchema not enabledPreview not rolled out
These rows are the audit trail of inbound connections your ingress network policy evaluated and (for DENY) blocked — the difference between "our IP allowlist is working" and "we never had one configured." A cluster of denials on a sensitive `request_path` from an unexpected `source_ip` is an early signal of a misconfigured client, a stale allowlist entry locking out legitimate users, or a probe against the account. Because the query does not filter the outcome, always confirm the `policy_outcome` column before treating a row as blocked — and note `DENY_DRY_RUN` rows were only simulated, not actually stopped. Equally important, an empty result is not an all-clear: it usually means no ingress policy is configured at all, so nothing is being enforced.
-- NEEDS CONFIRMATION: the source.ip nested subfield name is unverified in this account.
-- A wrong name errors the whole statement - drop or replace it with the verified subfield
-- before running this in your workspace.
SELECT policy_outcome, rule_label, request_path,
CASE
WHEN authenticated_as IS NULL OR authenticated_as = '__REDACTED__' THEN authenticated_as
WHEN authenticated_as LIKE '%@%' THEN concat(substr(authenticated_as, 1, 2), '****@****')
WHEN authenticated_as RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN authenticated_as
ELSE concat(substr(authenticated_as, 1, 2), '****')
END AS authenticated_as,
source.ip AS source_ip,
COUNT(*) AS denial_count,
MIN(event_time) AS first_event_time, MAX(event_time) AS last_event_time,
-- status: worst-first band on inbound denial volume per rule+path+principal+IP (field heuristic; :warn_denial_count / :crit_denial_count).
CASE
WHEN COUNT(*) >= :crit_denial_count THEN 'CRITICAL'
WHEN COUNT(*) >= :warn_denial_count THEN 'WARN'
ELSE 'OK'
END AS status
FROM system.access.inbound_network
WHERE event_time >= current_timestamp() - INTERVAL :period_days DAYS
GROUP BY 1, 2, 3, authenticated_as, 5
ORDER BY denial_count DESC:period_daysdefault 30rolling window in days (inbound retention caps at 30d regardless):warn_denial_countdefault 10denials for one rule+path+principal+IP that flags WARN:crit_denial_countdefault 50that flags CRITICALdenial_count plus the first_event_time and last_event_time, so you can tell a one-off from a sustained pattern.__REDACTED__ left as-is, NULL kept as NULL) so the output is safe to share.| Column | How to read it |
|---|---|
| policy_outcome | The enforcement result for the group — `DENY` (blocked for real) or `DENY_DRY_RUN` (would have been blocked, but the policy is in audit/simulation mode and traffic was let through). The query does not filter this column, so read it to know whether a row was actually blocked or only simulated. |
| rule_label | The label of the ingress-policy rule that matched. Identifies which rule in your network policy fired. |
| request_path | The inbound request path that was evaluated/denied — the endpoint/resource the connection was trying to reach. |
| authenticated_as | The principal behind the request, masked for sharing (`da****@****` for emails, `da****` for other names, UUIDs and `__REDACTED__` passed through, NULL left as NULL). Tells you who was blocked. |
| source_ip | The originating IP of the request (from the unverified `source.ip` subfield, aliased `source_ip`). Use it to distinguish a known-bad or unexpected network from a legitimate client caught by a stale allowlist. |
| denial_count | `COUNT(*)` of events in this outcome/rule/path/principal/IP group over the window. High counts mark sustained blocking; low counts a one-off. |
| first_event_time | `MIN(event_time)` — the earliest event in the group, i.e. when this pattern started. |
| last_event_time | `MAX(event_time)` — the most recent event in the group, i.e. whether it is still happening. |
status = OK; denial_count below :warn_denial_count for one rule+path+principal+IP - field heuristic.
status = WARN at/above :warn_denial_count, CRITICAL at/above :crit_denial_count - field heuristic; a sudden spike of denials from one source IP against multiple paths is worth prioritizing even below the threshold.
| policy_outcome | rule_label | request_path | authenticated_as | source_ip | denial_count | first_event_time | last_event_time |
|---|---|---|---|---|---|---|---|
| DENY | corp-ip-allowlist | /api/2.0/clusters/list | da****@**** | 203.0.113.47 | 128 | 2026-06-09 08:14:02 | 2026-07-06 22:51:37 |
| DENY | corp-ip-allowlist | /api/2.0/sql/statements | 5f3c9a12-4b7e-4c1a-9d2f-8e6b1a0c7d34 | 198.51.100.9 | 3 | 2026-06-28 03:02:10 | 2026-06-28 03:07:44 |
| DENY_DRY_RUN | tighten-egress-partners | /api/2.1/jobs/runs/submit | __REDACTED__ | 192.0.2.201 | 41 | 2026-06-15 11:20:55 | 2026-07-05 17:09:12 |
Triage the highest `denial_count` groups first: confirm each blocked `source_ip` / `authenticated_as` is genuinely unwanted (probe or misconfig) versus a legitimate client caught by a stale allowlist, then update the ingress rule accordingly. Because the query does not filter `policy_outcome`, scan the outcome column for any non-DENY values before assuming everything shown was blocked. If the result is empty, verify an ingress network policy is actually configured — the guardrail may not exist. Promote any `DENY_DRY_RUN` rules to real `DENY` once the simulated denials look correct.
Feeds into: network denials
A 30-day rollup of outbound (egress) network-policy denials, grouped by source and destination type, access type, and destination, with DNS/storage rejection detail and denial counts.
system.access.outbound_networkOne row = a source type x destination type x access type x destination combo in the window. The column that matters is denial_count - how many times egress to that destination was blocked.
RequiresSELECT on system.access; Public Preview
Returns no rows ifSchema not enabledPreview not rolled outServerless / warehouse only
This is your exfiltration alarm: it shows what outbound traffic your egress policy actually blocked, broken down by where it was headed and why it was rejected. A spike in denials to an unfamiliar storage bucket or DNS name can be an early signal of data leaving the guardrails — a leaked credential, a misconfigured job, or an insider moving data out. Because the surface only records denials with no allowed-traffic baseline, you cannot compute an allow/deny ratio here, and an empty result means "no egress policy configured / preview not populated," not "zero exfiltration" — so absence of rows is never proof you are safe.
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 denial_count,
MIN(event_time) AS first_event_time, MAX(event_time) AS last_event_time,
-- status: worst-first band on outbound denial volume per source+destination combo (field heuristic; :warn_denial_count / :crit_denial_count).
CASE
WHEN COUNT(*) >= :crit_denial_count THEN 'CRITICAL'
WHEN COUNT(*) >= :warn_denial_count THEN 'WARN'
ELSE 'OK'
END AS status
FROM system.access.outbound_network
WHERE event_time >= current_timestamp() - INTERVAL :period_days DAYS
GROUP BY 1, 2, 3, 4, 5, 6
ORDER BY denial_count DESC:period_daysdefault 30rolling window in days (outbound retention allows up to 365d):warn_denial_countdefault 10denials to one destination that flags WARN:crit_denial_countdefault 50that flags CRITICALdenial_count) and stamps when it was first and last seen.| Column | How to read it |
|---|---|
| network_source_type | The kind of workload/source the blocked egress originated from (e.g. serverless compute type). Use it to attribute denials to a source class. |
| destination_type | The category of the blocked destination — notably `DNS` or `STORAGE`, which govern whether the DNS or storage detail columns are populated. |
| access_type | The nature of the attempted access for this denial pattern. |
| destination | Where the traffic was trying to go (host, storage endpoint, or resolved name). Your primary triage field — unfamiliar destinations warrant investigation. |
| dns_rcode | The DNS response code for the denial; populated only when `destination_type = DNS`, otherwise `NULL`. |
| storage_rejection_reason | The reason the storage egress was rejected; populated only when `destination_type = STORAGE`, otherwise `NULL`. |
| denial_count | How many denial events matched this exact combination in the window. Integer count of blocked attempts — higher means a more persistent or busier blocked pattern. |
| first_event_time | Earliest denial timestamp for this pattern in the window — when this blocked behavior first appeared. |
| last_event_time | Most recent denial timestamp — whether this pattern is still active or has stopped. |
status = OK; denial_count below :warn_denial_count for one destination - field heuristic.
status = WARN at/above :warn_denial_count, CRITICAL at/above :crit_denial_count - field heuristic; a new destination_type=STORAGE denial with a non-null storage_rejection_reason is worth a look regardless of volume.
| network_source_type | destination_type | destination | dns_rcode | storage_rejection_reason | denial_count | last_event_time |
|---|---|---|---|---|---|---|
| SERVERLESS | DNS | exfil-c2.example.net | REFUSED | NULL | 214 | 2026-07-06 22:14:03 |
| SERVERLESS | STORAGE | s3://unknown-external-bucket | NULL | NOT_IN_ALLOWLIST | 37 | 2026-07-05 09:41:52 |
| SERVERLESS | EXTERNAL | 198.51.100.24:443 | NULL | NULL | 5 | 2026-07-02 17:08:11 |
Triage the highest `denial_count` rows and any unfamiliar `destination`: confirm whether each blocked egress is expected (a legitimate integration the policy is over-blocking) or hostile (potential exfiltration). For hostile patterns, trace the `network_source_type` back to the originating workload/principal and rotate credentials or lock down the job; for legitimate ones, adjust the egress allowlist. If the result is empty, verify an egress network policy is actually configured before concluding there is no exfiltration risk.
Feeds into: network denials
One row per direct column-lineage flow where a sensitivity-tagged source column fed an untagged target column, naming the responsible principal and how many times it happened.
system.access.column_lineageOne row = a specific source column (tagged sensitive) that fed, via direct lineage, into a specific target column carrying no governance tag at all. The columns that matter are event_count (how often that flow ran) and created_by (who to talk to about tagging the target).
RequiresSELECT on system.access and system.information_schema; system.access.column_lineage is GA; information_schema requires Unity Catalog
Returns no rows ifSchema not enabledNo SELECT privilegeLineage not inferableOutside retention window
Masking, row filters, and access policies are usually keyed off governance tags, so a sensitive value that flows into a new column which lost its tag silently escapes every one of those controls — the data is still PII, but nothing downstream treats it as such. This query pinpoints exactly those propagation gaps and hands you the principal (`created_by`) who created each flow, so you can re-tag the target and follow up on the pipeline that dropped the tag. Left unaddressed, these are the columns most likely to surface unmasked in a breach or an audit finding. Because tag coverage depends entirely on your own tagging discipline, this is a gap-finder, not a clean bill of health.
WITH lineage_window AS (
SELECT source_table_catalog, source_table_schema, source_table_name, source_column_name,
target_table_catalog, target_table_schema, target_table_name, target_column_name,
created_by
FROM system.access.column_lineage
WHERE direct_access = true -- direct flows only; excludes view expansion
AND source_column_name IS NOT NULL
AND target_column_name IS NOT NULL
AND source_table_catalog <> 'system'
AND event_date >= dateadd(day, -:period_days, current_date())
AND event_date < current_date()
),
-- Sensitivity-tagged columns (manual governance tags). We compare on the tag_name
-- against common PII/sensitivity conventions, case-insensitively. Confirm the real
-- tag_name(s) used in your account; if the convention differs this set under-detects,
-- which is the safe direction (we never invent a tag match).
sensitive_tags AS (
SELECT catalog_name, schema_name, table_name, column_name,
tag_name, tag_value
FROM system.information_schema.column_tags
WHERE lower(tag_name) IN (
'pii', 'sensitivity', 'sensitive', 'data_classification', 'classification',
'confidentiality', 'phi', 'pci'
)
OR lower(tag_value) IN (
'pii', 'sensitive', 'confidential', 'restricted', 'phi', 'pci'
)
),
-- Every column that carries ANY governance tag (used to decide if a TARGET is tagged
-- at all). Join with '=' on the four discrete identifier columns (never LIKE).
any_tagged_column AS (
SELECT DISTINCT catalog_name, schema_name, table_name, column_name
FROM system.information_schema.column_tags
)
SELECT lw.source_table_catalog,
lw.source_table_schema,
lw.source_table_name,
lw.source_column_name,
st.tag_name AS source_tag_name,
st.tag_value AS source_tag_value,
lw.target_table_catalog,
lw.target_table_schema,
lw.target_table_name,
lw.target_column_name,
CASE
WHEN lw.created_by IS NULL OR lw.created_by = '__REDACTED__' THEN lw.created_by
WHEN lw.created_by LIKE '%@%' THEN concat(substr(lw.created_by, 1, 2), '****@****')
WHEN lw.created_by RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN lw.created_by
ELSE concat(substr(lw.created_by, 1, 2), '****')
END AS created_by,
COUNT(*) AS event_count,
-- status: worst-first band on how often this untagged-PII-propagation edge fired (field heuristic; :warn_pii_gap_events / :crit_pii_gap_events).
CASE
WHEN COUNT(*) >= :crit_pii_gap_events THEN 'CRITICAL'
WHEN COUNT(*) >= :warn_pii_gap_events THEN 'WARN'
ELSE 'OK'
END AS status
FROM lineage_window lw
-- SOURCE column must be sensitivity-tagged (= join on discrete columns).
JOIN sensitive_tags st
ON lw.source_table_catalog = st.catalog_name
AND lw.source_table_schema = st.schema_name
AND lw.source_table_name = st.table_name
AND lw.source_column_name = st.column_name
-- TARGET column must be UNTAGGED: anti-join against any tag (= join, never LIKE).
LEFT JOIN any_tagged_column tt
ON lw.target_table_catalog = tt.catalog_name
AND lw.target_table_schema = tt.schema_name
AND lw.target_table_name = tt.table_name
AND lw.target_column_name = tt.column_name
WHERE tt.column_name IS NULL -- target carries NO governance tag -> propagation gap
GROUP BY lw.source_table_catalog, lw.source_table_schema, lw.source_table_name,
lw.source_column_name, st.tag_name, st.tag_value,
lw.target_table_catalog, lw.target_table_schema, lw.target_table_name,
lw.target_column_name, lw.created_by
ORDER BY event_count DESC:period_daysdefault 30rolling window in days:warn_pii_gap_eventsdefault 5times a source-to-target untagged-propagation edge fired that flags WARN:crit_pii_gap_eventsdefault 50that flags CRITICALcreated_by (email/name masked, service-principal UUIDs passed through) and counts how many lineage events (event_count) drove each source-to-target gap.:period_days, ending yesterday.| Column | How to read it |
|---|---|
| source_table_catalog | Catalog of the tagged source column that the sensitive data originated from. |
| source_table_schema | Schema of the tagged source column. |
| source_table_name | Table of the tagged source column. |
| source_column_name | The sensitivity-tagged source column — the thing that should keep propagating its classification. |
| source_tag_name | The governance tag key on the source (e.g. `pii`, `data_classification`) that marks it sensitive; free-text and case-sensitive in your metastore. |
| source_tag_value | The tag value on the source (e.g. `restricted`, `phi`); may be blank if only the tag_name conveyed sensitivity. |
| target_table_catalog | Catalog of the untagged target column the data flowed into. |
| target_table_schema | Schema of the untagged target column. |
| target_table_name | Table of the untagged target column. |
| target_column_name | The target column that received sensitive data but carries NO governance tag — the propagation gap to fix. |
| created_by | Masked identity of the principal responsible for the flow (email → `da****@****`, other names → first two chars + `****`, service-principal UUIDs and `__REDACTED__` passed through, NULL if unknown) — who to follow up with. |
| event_count | Number of lineage events observed for this exact source→target→principal gap in the window; higher means a recurring/automated pipeline, not a one-off. |
status = OK; event_count below :warn_pii_gap_events for one source-to-target propagation edge - field heuristic; any row here is already a gap, so "healthy" just means it is not yet a well-established pipeline.
status = WARN at/above :warn_pii_gap_events, CRITICAL at/above :crit_pii_gap_events - field heuristic; prioritize edges with a high event_count, since those are recurring, established pipelines, not one-off ad hoc queries.
| source_table_schema | source_column_name | source_tag_name | target_table_name | target_column_name | created_by | event_count |
|---|---|---|---|---|---|---|
| customers | pii | daily_export | email_addr | da****@**** | 14 | |
| hr | ssn | data_classification | comp_flat | ssn_raw | 2f3c9a10-4b7e-4c2a-9f11-8de0a1b2c3d4 | 3 |
| billing | card_pan | pci | fraud_features | pan_copy | jo**** | 27 |
Re-apply the correct governance tag to each `target_column_name` (and add a mask/row filter if your controls key off tags), then follow up with the masked `created_by` principal — prioritizing high `event_count` rows — to fix the pipeline that is dropping the classification.
Feeds into: PII column-propagation gaps (gov-2); masking/classification gaps with responsible created_by
A current-state list of every table in the metastore that has a row-level access filter attached, naming the filter and the columns it evaluates.
system.information_schema.row_filtersOne row = a table in the metastore that has a row-level access filter attached. The columns that matter are filter_name (which filter function) and target_columns (what it evaluates to decide row visibility).
RequiresSELECT on system.information_schema; Public Preview, DBR 12.2 LTS+, Unity Catalog required
Returns no rows ifNo SELECT privilegeABAC policies only
Row filters are the control that enforces row-level access — e.g. a rep only seeing their own region's rows — so this inventory is how you confirm those guardrails actually exist on the tables you think are protected. A sensitive table that is missing from this list has no row filter, meaning every reader who can query it sees all rows. Because the view is privilege-aware, a short list can equally mean "few filters" or "the collector can't see the rest," so treat it as a coverage floor, not a clean bill of health, when auditing least-privilege exposure.
-- It lists only manually-applied (table-level) row filters set via ALTER TABLE; ABAC tag-based policy filters do not appear here, so an ABAC-governed account can look falsely unfiltered - enumerate those via the UC REST API.
SELECT CATALOG_NAME AS table_catalog,
SCHEMA_NAME AS table_schema,
TABLE_NAME AS table_name,
FILTER_NAME AS filter_name,
FILTER_COL_USAGE AS target_columns
FROM system.information_schema.row_filters
ORDER BY table_catalog, table_schema, table_namefilter_name) and the columns it evaluates (target_columns) so you can see which policy governs each table and what it keys on.table_catalog, table_schema, table_name) so findings map directly to objects you can inspect or grant on.| Column | How to read it |
|---|---|
| table_catalog | Catalog of the filtered table — the first part of its three-level name. |
| table_schema | Schema (database) containing the filtered table. |
| table_name | Name of the table that has a row filter attached; each row here is a table confirmed to have row-level filtering. |
| filter_name | The name of the row-filter function bound to the table — the policy actually enforcing the row-level restriction. |
| target_columns | The column(s) the filter function reads to decide which rows a caller may see (e.g. a region or tenant key); tells you what the filter keys on. |
Inventory / reference query — it returns state to read, not a pass/fail band.
| table_catalog | table_schema | table_name | filter_name | target_columns |
|---|---|---|---|---|
| prod_sales | crm | opportunities | main.security.region_filter | ["region"] |
| prod_hr | people | compensation | main.security.dept_row_filter | ["department_id"] |
| prod_finance | ledger | transactions | main.security.tenant_scope | ["tenant_id","business_unit"] |
Cross-reference this list against your inventory of sensitive tables (from classification and tag inventories): any sensitive table NOT appearing here has no row filter and should be reviewed for a row-level policy — but first confirm the collector's visibility so you don't chase false gaps.
Feeds into: masking/row-filters inventory
A 90-day rollup of table-level data-flow edges, each classified as READ, WRITE, READ_WRITE, or UNKNOWN, with how many times it occurred, how many distinct principals drove it, and when it last happened.
system.access.table_lineageOne row = a source table x target table data-flow edge (plus type, entity_type, and direct_access) in the window. The columns that matter are access_class (READ/WRITE/READ_WRITE/UNKNOWN) and distinct_principals - how many different identities drove that specific flow. This is per-edge, not a full fan-out count; to see everything one source table touches, aggregate this result by source_table_full_name yourself.
RequiresSELECT on system.access; GA
Returns no rows ifSchema not enabledNo SELECT privilegeLineage not inferable
Before you alter or drop a table, you need to know what reads from it and who depends on it — dropping a table that silently feeds a dozen downstream tables or reports is a self-inflicted outage. This query turns raw lineage into a per-edge blast-radius map so an admin can see the downstream reach and the number of distinct principals touching each relationship. The critical risk is misreading it: because MERGE, JDBC, path-based, and temp-view operations are never captured, an empty result is a coverage gap, not proof the table is unused — treating it as 'safe to drop' is how you break a pipeline nobody knew existed.
SELECT source_table_full_name, target_table_full_name, source_type, target_type,
entity_type, direct_access,
CASE WHEN source_type IS NOT NULL AND target_type IS NULL THEN 'READ'
WHEN target_type IS NOT NULL AND source_type IS NULL THEN 'WRITE'
WHEN source_type IS NOT NULL AND target_type IS NOT NULL THEN 'READ_WRITE'
ELSE 'UNKNOWN' END AS access_class,
COUNT(*) AS event_count,
COUNT(DISTINCT created_by) AS distinct_principals,
MAX(event_time) AS last_event_time,
-- status: worst-first band on how many distinct principals drove this source-target edge (field heuristic; :warn_blast_principals / :crit_blast_principals).
CASE
WHEN COUNT(DISTINCT created_by) >= :crit_blast_principals THEN 'CRITICAL'
WHEN COUNT(DISTINCT created_by) >= :warn_blast_principals THEN 'WARN'
ELSE 'OK'
END AS status
FROM system.access.table_lineage
WHERE event_date >= current_date() - INTERVAL :period_days DAYS
AND event_date < current_date()
GROUP BY 1, 2, 3, 4, 5, 6, 7
ORDER BY distinct_principals DESC:period_daysdefault 30rolling window in days:warn_blast_principalsdefault 10distinct principals driving one source-target edge that flags WARN:crit_blast_principalsdefault 50that flags CRITICAL| Column | How to read it |
|---|---|
| source_table_full_name | Fully-qualified name of the upstream table being read from; NULL on write-only events (nothing was read). |
| target_table_full_name | Fully-qualified name of the downstream table being written to; NULL on read-only events (nothing was written). |
| source_type | Entity type of the source side (e.g. TABLE, PATH); NULL when there is no source, which the CASE reads as a WRITE. |
| target_type | Entity type of the target side; NULL when there is no target, which the CASE reads as a READ. |
| entity_type | The kind of operation/entity that produced the lineage edge (the observed job/query/entity category). |
| direct_access | true = a direct read/write of the table; false = indirect access via view expansion — do not count indirect edges as first-hand usage. |
| access_class | Derived label from the source_type/target_type NULL pattern: READ (source only), WRITE (target only), READ_WRITE (both present), or UNKNOWN (neither) — the plain-English direction of the flow. |
| event_count | Number of lineage events (COUNT(*)) collapsed into this edge over the 90-day window — a usage-frequency proxy, not a cost or row count. |
| distinct_principals | Count of distinct `created_by` identities that drove this edge — how many different people/service principals depend on the relationship. |
| last_event_time | Most recent event_time (MAX) this edge was observed — use it to tell live relationships from stale ones. |
status = OK; distinct_principals below :warn_blast_principals for one source-target edge - field heuristic.
status = WARN at/above :warn_blast_principals, CRITICAL at/above :crit_blast_principals - field heuristic; an edge with direct_access=true, a sensitive-looking source, and high distinct_principals is the highest-priority combination to review.
| source_table_full_name | target_table_full_name | access_class | direct_access | event_count | distinct_principals | last_event_time |
|---|---|---|---|---|---|---|
| prod.sales.orders | NULL | READ | true | 4210 | 17 | 2026-07-05 23:41:02 |
| NULL | prod.finance.daily_revenue | WRITE | true | 96 | 2 | 2026-07-05 05:12:44 |
| prod.sales.orders | prod.finance.daily_revenue | READ_WRITE | false | 31 | 1 | 2026-07-04 05:12:40 |
Pick the table you plan to change or drop, filter to rows where it appears as `source_table_full_name` (access_class READ / READ_WRITE), and read off `distinct_principals` and the downstream `target_table_full_name` values to size the blast radius before you touch it — then cross-check against `system.access.audit` and query history, because empty lineage means "not captured," not "safe to drop."
Feeds into: column-lineage blast radius (table-level rollup); access history; lineage coverage
A rollup of every manual governance tag applied at column and table scope, showing each tag name/value pair and how many objects carry it.
system.information_schema.column_tagsOne row = a tag name/value pair at column or table scope, with how many objects carry it. Use it to see your account's actual tagging vocabulary in practice.
RequiresSELECT on system.information_schema; Unity Catalog required
Returns no rows ifNo SELECT privilege
Manual tags are how you assert governance intent — marking columns and tables as PII, confidential, cost-center-owned, or retention-bound — and they drive tag-based access policies and downstream lineage checks. This query tells you whether that tagging discipline actually exists or is aspirational: sparse or inconsistent tag values mean policies keyed on tags silently protect nothing. It is the manual-tag counterpart to auto-detected classification, so gaps here are places where humans, not the scanner, were supposed to label sensitivity and did not.
SELECT 'COLUMN' AS object_scope, TAG_NAME, TAG_VALUE,
COUNT(*) AS tagged_object_count
FROM system.information_schema.column_tags
GROUP BY 1, 2, 3
UNION ALL
SELECT 'TABLE' AS object_scope, TAG_NAME, TAG_VALUE,
COUNT(*) AS tagged_object_count
FROM system.information_schema.table_tags
GROUP BY 1, 2, 3
ORDER BY object_scope, TAG_NAME, TAG_VALUECOLUMN and TABLE scope — catalog, schema, and volume tags are excluded as unverified.| Column | How to read it |
|---|---|
| object_scope | Which object level the tag sits on: the literal `'COLUMN'` (from column_tags) or `'TABLE'` (from table_tags). Use it to separate fine-grained column labels from coarser table labels. |
| TAG_NAME | The free-text, case-sensitive tag key (e.g. `pii`, `data_sensitivity`, `cost_center`). Treat two names differing only in case as distinct. |
| TAG_VALUE | The free-text, case-sensitive value assigned for that tag name (e.g. `high`, `confidential`). May be blank if tags are applied as keys without values. |
| tagged_object_count | Number of objects (columns or tables, per object_scope) carrying that exact tag name/value pair. An integer count of objects, privilege-scoped to what the running principal can see — a low number can mean thin tagging or limited visibility. |
Inventory / reference query — it returns state to read, not a pass/fail band.
| object_scope | TAG_NAME | TAG_VALUE | tagged_object_count |
|---|---|---|---|
| COLUMN | pii | 42 | |
| COLUMN | data_sensitivity | high | 118 |
| TABLE | cost_center | finance | 7 |
Rank tag name/value pairs by tagged_object_count to find sparsely-used or fragmented (case/spelling-inconsistent) tags, then reconcile column-scope sensitivity tags against auto-classification results to find sensitive columns humans failed to label — and confirm any tag-based access policies are keyed to values that objects actually carry.
Feeds into: classification (manual tags)
A daily count of Vector Search query and scan events per endpoint over the trailing window, used to tell which endpoints are actually being used versus provisioned-but-idle.
system.access.auditOne row = an endpoint x action x day, with how many query/scan events it received. The column that matters is event_count - join this against your own billing data to tell a provisioned-but-unqueried endpoint (bills spend, zero rows here) from a genuinely idle one.
RequiresSELECT on system.access; Public Preview
Returns no rows ifSchema not enabledNo SELECT privilegePreview not rolled outVerbose audit off
Vector Search endpoints bill for provisioned compute whether or not anything queries them, so an endpoint that costs money every day but serves zero query/scan traffic is pure waste. This query is the traffic half of idle-endpoint detection: cross-referenced with Vector Search spend, an endpoint that shows spend but has NO row here is a provisioned-but-unqueried retire candidate. Retiring dead endpoints directly removes recurring compute charges, while confirming live traffic protects you from cutting one that is quietly in use.
SELECT event_date,
action_name,
CASE WHEN request_params['endpoint_name'] IS NULL THEN request_params['endpoint_name'] ELSE concat(substr(request_params['endpoint_name'], 1, 2), '****') END AS endpoint_name,
COUNT(*) AS event_count
FROM system.access.audit
WHERE service_name = 'vectorSearch'
AND action_name IN (
'queryVectorIndex', 'queryVectorIndexNextPage',
'queryVectorIndexRouteOptimized', 'scanVectorIndex',
'scanVectorIndexRouteOptimized')
AND event_date >= dateadd(day, -:period_days, current_date())
AND event_date < current_date()
GROUP BY event_date, action_name, request_params['endpoint_name']
ORDER BY event_date DESC, endpoint_name, action_name:period_daysdefault 30rolling window in days.| Column | How to read it |
|---|---|
| event_date | The calendar day (partition column) on which the Vector Search events occurred; the window is the trailing :period_days days and excludes today. |
| action_name | The specific Vector Search operation — one of queryVectorIndex, queryVectorIndexNextPage, queryVectorIndexRouteOptimized, scanVectorIndex, or scanVectorIndexRouteOptimized. Query = live retrieval, scan = index scan. |
| endpoint_name | The Vector Search endpoint that received the traffic, truncated to its first two characters plus '****' for safe sharing; NULL passes through if the audit event carried no endpoint_name. Match back to a full name by prefix when correlating with spend. |
| event_count | Number of audited events for that day/action/endpoint combination. A positive count is proof of use; no row at all for an endpoint means no query/scan traffic in the window. |
Inventory / reference query — it returns state to read, not a pass/fail band.
| event_date | action_name | endpoint_name | event_count |
|---|---|---|---|
| 2026-07-02 | queryVectorIndex | pr**** | 1842 |
| 2026-07-02 | queryVectorIndexNextPage | pr**** | 310 |
| 2026-07-03 | scanVectorIndex | st**** | 4 |
Join this endpoint-traffic list against your Vector Search spend: any endpoint that incurs spend but has zero rows here over a representative window is a retire candidate — confirm ownership, then decommission or downsize it to stop the recurring compute charge. Where traffic exists, keep the endpoint and note its usage pattern.
Feeds into: Vector Search idle-endpoint detection (which endpoints received query/scan traffic in the window) — joined in-engine to cost_vector_search_spend
One row per <em>outbound</em> Delta Share, with how many recipients it’s granted to and how many schemas and tables it exposes — your external data footprint, worst-first.
system.information_schema.sharessystem.information_schema.share_recipient_privilegessystem.information_schema.table_share_usagesystem.information_schema.schema_share_usageOne row = one OUTBOUND Delta Share, with how many recipients it is granted to and how many schemas/tables it exposes. The columns that matter are recipient_count and shared_table_count — together they size external data exposure. A share with recipients IS live external exposure.
RequiresSELECT on system.information_schema; Unity Catalog required; Delta Sharing (provider side) in use
Returns no rows ifNo SELECT privilegeFeature not used yet
Delta Sharing is the one governance surface where your data physically leaves the account. Every outbound share is a standing grant to an external recipient — and unlike a table grant inside Unity Catalog, you can’t watch who reads it in the query history. The only inventory you have is the share definition itself: who it’s granted to, and what it exposes.
This query sizes that external footprint in one pass. It counts distinct recipients per share and how many schemas and tables each one exposes, then bands them worst-first — so a share with a dozen recipients or a hundred shared tables surfaces above a draft share that was defined but never granted out. A share with recipients is live external exposure; this is the list you confirm is intentional before an auditor asks.
WITH recips AS (
SELECT SHARE_NAME,
COUNT(DISTINCT RECIPIENT_NAME) AS recipient_count,
array_join(collect_set(
CASE WHEN RECIPIENT_NAME IS NULL OR RECIPIENT_NAME = '__REDACTED__' THEN RECIPIENT_NAME
WHEN RECIPIENT_NAME LIKE '%@%' THEN concat(substr(RECIPIENT_NAME, 1, 2), '****@****')
WHEN RECIPIENT_NAME RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN RECIPIENT_NAME
ELSE concat(substr(RECIPIENT_NAME, 1, 2), '****') END), ', ') AS recipients
FROM system.information_schema.share_recipient_privileges
GROUP BY SHARE_NAME
),
tbls AS (
SELECT SHARE_NAME, COUNT(*) AS shared_table_count
FROM system.information_schema.table_share_usage GROUP BY SHARE_NAME
),
schs AS (
SELECT SHARE_NAME, COUNT(*) AS shared_schema_count
FROM system.information_schema.schema_share_usage GROUP BY SHARE_NAME
)
SELECT
s.SHARE_NAME AS share_name,
CASE WHEN s.SHARE_OWNER IS NULL OR s.SHARE_OWNER = '__REDACTED__' THEN s.SHARE_OWNER
WHEN s.SHARE_OWNER LIKE '%@%' THEN concat(substr(s.SHARE_OWNER, 1, 2), '****@****')
WHEN s.SHARE_OWNER RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN s.SHARE_OWNER
ELSE concat(substr(s.SHARE_OWNER, 1, 2), '****') END AS share_owner,
COALESCE(r.recipient_count, 0) AS recipient_count,
r.recipients AS recipients,
COALESCE(t.shared_table_count, 0) AS shared_table_count,
COALESCE(sc.shared_schema_count, 0) AS shared_schema_count,
s.CREATED AS created,
CASE
WHEN COALESCE(r.recipient_count, 0) >= :crit_share_recipients
OR COALESCE(t.shared_table_count, 0) >= :crit_shared_tables THEN 'CRITICAL'
WHEN COALESCE(r.recipient_count, 0) >= :warn_share_recipients THEN 'WARN'
ELSE 'OK'
END AS status
FROM system.information_schema.shares s
LEFT JOIN recips r ON r.SHARE_NAME = s.SHARE_NAME
LEFT JOIN tbls t ON t.SHARE_NAME = s.SHARE_NAME
LEFT JOIN schs sc ON sc.SHARE_NAME = s.SHARE_NAME
ORDER BY
CASE status WHEN 'CRITICAL' THEN 0 WHEN 'WARN' THEN 1 ELSE 2 END,
recipient_count DESC, shared_table_count DESC
LIMIT :top_n:warn_share_recipientsdefault 1recipients on a share at/above which it flags WARN (any external recipient is worth a review):crit_share_recipientsdefault 10recipients at/above which it flags CRITICAL:crit_shared_tablesdefault 100shared tables at/above which a share flags CRITICAL regardless of recipient count:top_ndefault 200row capsystem.information_schema.shares — what you share out, never data shared to you.share_recipient_privileges, partial-masking each recipient name so the row is safe to paste into a ticket.table_share_usage and schema_share_usage to size what each share exposes — shared_table_count and shared_schema_count.:crit_share_recipients or shared tables reach :crit_shared_tables, WARN at :warn_share_recipients (any external recipient is worth a look), OK when a share has no recipients yet — a labelled field heuristic.| Column | How to read it |
|---|---|
| share_name | The outbound share’s name — your unit of external exposure. |
| share_owner | The principal that owns the share, partial-masked (first 2 chars + '****', emails and GUIDs handled). |
| recipient_count | DISTINCT recipients this share is granted to. 0 = defined but not yet granted out (not live yet); any positive number is live external exposure. |
| recipients | The masked recipient names, comma-joined — orientation only, not a full identity. |
| shared_table_count | Tables this share exposes (from table_share_usage). Can lag recent share edits. |
| shared_schema_count | Schemas this share exposes (from schema_share_usage) — a schema share grows automatically as tables are added to it. |
| created | When the share was created — orientation for how long the exposure has stood. |
| status | Worst-first band: CRITICAL (recipients ≥ :crit_share_recipients or tables ≥ :crit_shared_tables), WARN (recipients ≥ :warn_share_recipients), OK (no recipients yet). Field heuristic — tune the thresholds. |
recipient_count = 0 - a share defined but not yet granted to any recipient (field heuristic).
recipient_count at/above :warn_share_recipients (WARN - external exposure exists, confirm it is intended); recipient_count at/above :crit_share_recipients or shared_table_count at/above :crit_shared_tables (CRITICAL - broad external exposure) - field heuristic.
| share_name | share_owner | recipient_count | recipients | shared_table_count | shared_schema_count | status |
|---|---|---|---|---|---|---|
| partner_analytics_share | da**** | 12 | ac****@****, in****@****, … | 240 | 3 | CRITICAL |
| vendor_reporting_feed | da**** | 2 | ve****@****, ex**** | 18 | 1 | WARN |
| internal_draft_share | da**** | 0 | (null) | 0 | 0 | OK |
system.information_schema is privilege-aware: a principal that can’t see a share or its recipients undercounts exposure — label the result “partial — privilege-aware,” never a complete share graph.recipient_count is DISTINCT recipients across the share’s privilege rows; recipient names are partial-masked.shared_table_count / shared_schema_count come from table_share_usage / schema_share_usage (what’s actually exposed) and can lag share edits.recipient_count = 0 means defined-but-not-yet-granted-out — not necessarily safe, just not yet live.Work the list worst-first. For every share with recipients, confirm each is an intended partner and revoke any that aren’t (free); scope shares to the minimum tables and schemas needed, preferring schema shares with row/column controls (config); and rotate recipient tokens with IP allowlists and expirations set (config). Then cross to the internal grant side of the same objects, and check whether the shared tables even carry sensitivity tags.
Feeds into: per-SHARE external exposure (recipients + shared tables/schemas, banded worst-first) — the outbound Delta Sharing footprint that pairs with the internal grants inventory and the sensitivity-tag inventory of the shared objects
One row per view or materialized view you can see — whether it’s materialized, its <code>sql_path</code>, and the size of its definition — the lineage-completeness and MV-cost reference the other queries lean on.
system.information_schema.viewsOne row = one view (or materialized view) visible to your grants, with whether it is materialized, updatable/insertable, its sql_path, and the size of its definition. Use it to complete the lineage picture (views mediate table-to-table flows that table lineage may not attribute) and to inventory materialized views (which carry compute + storage cost).
RequiresSELECT on system.information_schema; Unity Catalog required
Returns no rows ifNo SELECT privilegeFeature not used yet
Views are the quiet middle layer of a lakehouse. They mediate table-to-table flows that column lineage sometimes can’t attribute, they can wrap a masked table (or fail to), and materialized views quietly carry both compute and storage cost. Yet they rarely surface in an access review, because a view is neither a grant nor a table.
This is the plain inventory that puts them on the map: one row per view or materialized view you can see, with whether it’s materialized, whether it’s updatable, its sql_path, and the size of its definition. It’s a reference and join input — use it to complete a lineage picture or to find the materialized views worth cost-checking. The view’s SQL body is never emitted.
SELECT
t.TABLE_CATALOG AS view_catalog,
t.TABLE_SCHEMA AS view_schema,
t.TABLE_NAME AS view_name,
(t.TABLE_TYPE = 'MATERIALIZED_VIEW') AS is_materialized,
v.IS_UPDATABLE AS is_updatable,
v.IS_INSERTABLE_INTO AS is_insertable_into,
v.SQL_PATH AS sql_path,
LENGTH(v.VIEW_DEFINITION) AS definition_chars,
CASE WHEN upper(v.VIEW_DEFINITION) LIKE '%MASK%' OR upper(v.VIEW_DEFINITION) LIKE '%FILTER%'
THEN true ELSE false END AS references_mask_or_filter
FROM system.information_schema.tables t
LEFT JOIN system.information_schema.views v
ON v.TABLE_CATALOG = t.TABLE_CATALOG
AND v.TABLE_SCHEMA = t.TABLE_SCHEMA
AND v.TABLE_NAME = t.TABLE_NAME
WHERE t.TABLE_TYPE IN ('VIEW', 'MATERIALIZED_VIEW')
ORDER BY view_catalog, view_schema, view_name
LIMIT :top_n:top_ndefault 500row cap - current-state inventory, no window.system.information_schema.views.is_materialized (a compute + storage cost signal), plus is_updatable / is_insertable_into.sql_path and the length of the definition (definition_chars) — never the VIEW_DEFINITION SQL itself, which can carry sensitive logic.references_mask_or_filter — a text heuristic (the definition mentions MASK/FILTER), not authoritative masked-view detection.| Column | How to read it |
|---|---|
| view_catalog | Catalog the view lives in. |
| view_schema | Schema the view lives in. |
| view_name | The view (or materialized view) name. |
| is_materialized | true = a materialized view — it stores results and carries compute + storage cost, so it’s worth cross-checking against spend. |
| is_updatable | Whether the view accepts UPDATE/DELETE (a simple, single-table view). |
| is_insertable_into | Whether the view accepts INSERT. |
| sql_path | The view’s resolution path, when set — orientation for how it resolves object names. |
| definition_chars | The length of the view definition in characters (a proxy for complexity). The SQL body itself is deliberately not emitted. |
| references_mask_or_filter | Text heuristic: the definition string mentions MASK or FILTER. NOT authoritative — a view can wrap a masked table without saying so, and can mention the words for unrelated reasons. |
Inventory / reference query — it returns state to read, not a pass/fail band.
| view_catalog | view_schema | view_name | is_materialized | is_insertable_into | definition_chars | references_mask_or_filter |
|---|---|---|---|---|---|---|
| main | analytics | daily_revenue_mv | true | NO | 412 | false |
| main | governance | customers_masked | false | NO | 1180 | true |
| main | sales | active_orders | false | NO | 256 | false |
view_definition SQL is not emitted (it can contain sensitive logic) — only its character length (definition_chars) and a derived flag.references_mask_or_filter is a text heuristic (the definition string mentions MASK/FILTER) and is not authoritative masked-view detection — a view can wrap a masked table without saying so, and a definition can mention the words for unrelated reasons.system.information_schema is privilege-aware, so views the principal can’t see are absent.is_materialized separates materialized views (compute/storage cost) from plain views.Treat it as a join input, not a finding. Use it to complete the lineage picture — views mediate table-to-table flows that table lineage may not attribute — and pull the is_materialized = true rows to cross-check their compute and storage spend. From here, follow the table lineage these views mediate, or the dead-table candidates worth retiring.
Feeds into: the view + materialized-view inventory (is_materialized, updatable, sql_path, definition size, a mask/filter text flag) — the lineage-completeness and MV-cost reference input for the governance and storage domains
One row per Unity Catalog volume (data that lives as <em>files</em>, outside the table surface), with its type, storage location, and tag coverage — <code>tag_count = 0</code> is an unclassified file store masks can’t reach.
system.information_schema.volumessystem.information_schema.volume_tagsOne row = one Unity Catalog volume (files that live OUTSIDE tables), with its type, storage location, and whether it carries any governance tag. The column that matters is tag_count = 0 - an unclassified file store, a common PII blind spot, since data classification and column masks apply to TABLES, not volume files.
RequiresSELECT on system.information_schema; Unity Catalog required
Returns no rows ifNo SELECT privilegeFeature not used yet
Unity Catalog governs tables beautifully — column masks, row filters, auto-classification — but a lot of real data never lands in a table. It sits in volumes: raw files, drop zones, model artifacts, exports. None of the table-level controls touch a volume, so an untagged volume is a governance blind spot by construction, not by neglect.
This is the inventory that makes those blind spots visible. One row per volume, with its type (managed vs external), its storage location, and its tag coverage. The column that matters is tag_count = 0 — an unclassified file store — and it’s worse on an external volume, where the files sit in your own cloud bucket, outside Databricks-managed storage entirely.
WITH vt AS (
SELECT CATALOG_NAME, SCHEMA_NAME, VOLUME_NAME,
COUNT(*) AS tag_count,
array_join(collect_set(TAG_NAME), ', ') AS tag_names
FROM system.information_schema.volume_tags
GROUP BY CATALOG_NAME, SCHEMA_NAME, VOLUME_NAME
)
SELECT
v.VOLUME_CATALOG AS volume_catalog,
v.VOLUME_SCHEMA AS volume_schema,
v.VOLUME_NAME AS volume_name,
v.VOLUME_TYPE AS volume_type,
CASE WHEN v.VOLUME_OWNER IS NULL OR v.VOLUME_OWNER = '__REDACTED__' THEN v.VOLUME_OWNER
WHEN v.VOLUME_OWNER LIKE '%@%' THEN concat(substr(v.VOLUME_OWNER, 1, 2), '****@****')
WHEN v.VOLUME_OWNER RLIKE '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN v.VOLUME_OWNER
ELSE concat(substr(v.VOLUME_OWNER, 1, 2), '****') END AS volume_owner,
v.STORAGE_LOCATION AS storage_location,
COALESCE(vt.tag_count, 0) AS tag_count,
vt.tag_names AS tag_names,
v.CREATED AS created,
CASE
WHEN COALESCE(vt.tag_count, 0) = 0 AND upper(v.VOLUME_TYPE) = 'EXTERNAL' THEN 'CRITICAL'
WHEN COALESCE(vt.tag_count, 0) = 0 THEN 'WARN'
ELSE 'OK'
END AS status
FROM system.information_schema.volumes v
LEFT JOIN vt
ON vt.CATALOG_NAME = v.VOLUME_CATALOG AND vt.SCHEMA_NAME = v.VOLUME_SCHEMA AND vt.VOLUME_NAME = v.VOLUME_NAME
ORDER BY
CASE status WHEN 'CRITICAL' THEN 0 WHEN 'WARN' THEN 1 ELSE 2 END,
v.VOLUME_CATALOG, v.VOLUME_SCHEMA, v.VOLUME_NAME
LIMIT :top_n:top_ndefault 500row cap - current-state inventory, no window.system.information_schema.volumes — the file-based data that lives outside the table surface.volume_tags to compute tag_count — how many governance tags each volume carries — and rolls up the tag names.storage_location (the raw managed/external path) and partial-masks the volume_owner.| Column | How to read it |
|---|---|
| volume_catalog | Catalog the volume lives in. |
| volume_schema | Schema the volume lives in. |
| volume_name | The volume name. |
| volume_type | MANAGED (Databricks-managed storage) or EXTERNAL (your own cloud path) — EXTERNAL untagged is the worse case. |
| volume_owner | Owning principal, partial-masked (first 2 chars + '****', emails and GUIDs handled). |
| storage_location | The raw managed/external path. Sensitive — treat the result as confidential. |
| tag_count | DISTINCT governance tags on the volume. 0 is the signal — an unclassified file store. |
| tag_names | The tag names present (comma-joined), or null when untagged. |
| created | When the volume was created — orientation for how long an untagged store has existed. |
| status | Worst-first band: CRITICAL (untagged + EXTERNAL), WARN (untagged managed), OK (any tag present). Field heuristic — tune to your tagging policy. |
tag_count above 0 - the volume carries at least one governance tag (field heuristic).
tag_count = 0 on an EXTERNAL volume (CRITICAL - unclassified files in external storage) or on a managed volume (WARN - unclassified file store) - field heuristic; tune to your tagging policy.
| volume_catalog | volume_schema | volume_name | volume_type | storage_location | tag_count | tag_names | status |
|---|---|---|---|---|---|---|---|
| raw | landing | customer_dropbox | EXTERNAL | s3://…/customer_dropbox | 0 | (null) | CRITICAL |
| main | ml | feature_artifacts | MANAGED | (managed) | 0 | (null) | WARN |
| main | analytics | export_stage | MANAGED | (managed) | 2 | owner, pii | OK |
system.information_schema is privilege-aware, so volumes the principal can’t see are absent.tag_count is DISTINCT tags from volume_tags joined on (catalog, schema, volume).storage_location is the raw external/managed path and is sensitive — treat the result as confidential. volume_owner is partial-masked.Tag the volumes that hold sensitive files with your classification tags so they show up in governance rollups (free/config); for external volumes, confirm the storage_location is an intended, access-controlled bucket (config); and move any PII that belongs in a governed table out of raw volume files, where masks and row filters can actually protect it (spend/process). From here, check the account’s tag vocabulary and the table-level classification these files are invisible to.
Feeds into: the UC volume inventory + per-volume tag coverage (managed vs external, storage location, tag_count) — the file-outside-tables blind-spot list that feeds access_pii_outside_tables and pairs with table-level data classification
One row per volume or schema that carries a sensitivity tag (PII outside the table surface) or is an untagged volume (an unclassified file store) — the file/schema counterpart to the column-level PII check.
system.information_schema.volumessystem.information_schema.volume_tagssystem.information_schema.schema_tagsOne row = a VOLUME or SCHEMA that either carries a sensitivity/PII tag (known sensitive data living OUTSIDE the table surface, where column masks / row filters / auto-classification do NOT apply) or is an UNTAGGED volume (an unclassified file store). The status band says which. This is the file/schema counterpart to access_classified_unmasked, which only sees table columns.
RequiresSELECT on system.information_schema; Unity Catalog required
Returns no rows ifNo SELECT privilegeFeature not used yet
Every other PII control in this domain looks at table columns — masks, row filters, the auto-classifier. But sensitive data doesn’t only live in columns. It lives in volume files and gets flagged at the schema level, and neither surface is reachable by a column mask. That’s the gap this query closes.
It’s the finding view over the volume and schema tags: one row per volume or schema that either carries a sensitivity tag (known PII living outside the table surface) or is an untagged volume (an unclassified file store that might hold PII). It bands them worst-first, with sensitive or untagged external volumes at the top — sensitive files in your own bucket that no Databricks control can mask.
SELECT
object_type, catalog_name, schema_name, object_name, object_detail, finding, tags,
CASE
WHEN is_sensitive = 1 AND is_external = 1 THEN 'CRITICAL'
WHEN untagged = 1 AND is_external = 1 THEN 'CRITICAL'
WHEN is_sensitive = 1 THEN 'WARN'
WHEN untagged = 1 THEN 'WARN'
ELSE 'OK'
END AS status
FROM (
-- VOLUMES: sensitive-tagged (known PII files) or untagged (blind spot)
SELECT
'VOLUME' AS object_type,
v.VOLUME_CATALOG AS catalog_name,
v.VOLUME_SCHEMA AS schema_name,
v.VOLUME_NAME AS object_name,
v.VOLUME_TYPE AS object_detail,
CASE WHEN upper(v.VOLUME_TYPE) = 'EXTERNAL' THEN 1 ELSE 0 END AS is_external,
COALESCE(vt.is_sensitive, 0) AS is_sensitive,
CASE WHEN COALESCE(vt.tag_count, 0) = 0 THEN 1 ELSE 0 END AS untagged,
vt.tag_names AS tags,
CASE WHEN COALESCE(vt.is_sensitive, 0) = 1 THEN 'sensitive-tagged (PII files outside governed tables)'
WHEN COALESCE(vt.tag_count, 0) = 0 THEN 'untagged (unclassified file store)'
ELSE 'tagged (non-sensitive)' END AS finding
FROM system.information_schema.volumes v
LEFT JOIN (
SELECT CATALOG_NAME, SCHEMA_NAME, VOLUME_NAME,
COUNT(*) AS tag_count,
MAX(CASE WHEN TAG_NAME RLIKE '(?i)(pii|sensitiv|confidential|gdpr|personal|secret|restricted)'
OR TAG_VALUE RLIKE '(?i)(pii|sensitiv|confidential|gdpr|personal|secret|restricted)'
THEN 1 ELSE 0 END) AS is_sensitive,
array_join(collect_set(TAG_NAME), ', ') AS tag_names
FROM system.information_schema.volume_tags
GROUP BY CATALOG_NAME, SCHEMA_NAME, VOLUME_NAME
) vt
ON vt.CATALOG_NAME = v.VOLUME_CATALOG AND vt.SCHEMA_NAME = v.VOLUME_SCHEMA AND vt.VOLUME_NAME = v.VOLUME_NAME
UNION ALL
-- SCHEMAS: only those carrying a sensitivity tag (a namespace flagged sensitive)
SELECT
'SCHEMA' AS object_type,
st.CATALOG_NAME AS catalog_name,
st.SCHEMA_NAME AS schema_name,
st.SCHEMA_NAME AS object_name,
'schema tag' AS object_detail,
0 AS is_external,
1 AS is_sensitive,
0 AS untagged,
array_join(collect_set(concat(st.TAG_NAME, '=', st.TAG_VALUE)), ', ') AS tags,
'sensitive-tagged schema (namespace flagged sensitive)' AS finding
FROM system.information_schema.schema_tags st
WHERE st.TAG_NAME RLIKE '(?i)(pii|sensitiv|confidential|gdpr|personal|secret|restricted)'
OR st.TAG_VALUE RLIKE '(?i)(pii|sensitiv|confidential|gdpr|personal|secret|restricted)'
GROUP BY st.CATALOG_NAME, st.SCHEMA_NAME
)
WHERE is_sensitive = 1 OR untagged = 1 -- findings only (drop tagged-but-non-sensitive volumes)
ORDER BY
CASE status WHEN 'CRITICAL' THEN 0 WHEN 'WARN' THEN 1 ELSE 2 END,
object_type, catalog_name, schema_name, object_name
LIMIT :top_n:top_ndefault 500row cap - current-state, no window.pii|sensitiv|confidential|gdpr|personal|secret|restricted) — a heuristic to tune to your tag vocabulary, not an authoritative classifier.access_volumes_inventory.| Column | How to read it |
|---|---|
| object_type | VOLUME (files outside tables) or SCHEMA (a namespace flagged sensitive). |
| catalog_name | Catalog of the flagged object. |
| schema_name | Schema of the flagged object (or the schema itself, for a SCHEMA row). |
| object_name | The volume or schema name. |
| object_detail | For a volume, its type (MANAGED/EXTERNAL); for a schema, schema tag. |
| finding | Plain-language reason it’s here: sensitive-tagged (PII files outside governed tables), untagged (unclassified file store), or sensitive-tagged schema. |
| tags | The matching tag names/values that triggered the finding. |
| status | Worst-first band: CRITICAL (sensitive or untagged EXTERNAL volume), WARN (sensitive managed volume/schema, or untagged managed volume). Field heuristic — tune the sensitivity regex to your tag vocabulary. |
a tagged, non-sensitivity-tagged volume (field heuristic) - the file store is inventoried and not flagged sensitive.
a sensitivity-tagged or untagged EXTERNAL volume (CRITICAL - sensitive/unclassified files in external storage that masks cannot protect); a sensitivity-tagged managed volume or schema, or an untagged managed volume (WARN) - field heuristic; tune the sensitivity regex in the SQL to your tag vocabulary.
| object_type | catalog_name | schema_name | object_name | object_detail | finding | status |
|---|---|---|---|---|---|---|
| VOLUME | raw | landing | customer_dropbox | EXTERNAL | sensitive-tagged (PII files outside governed tables) | CRITICAL |
| VOLUME | main | ml | feature_artifacts | MANAGED | untagged (unclassified file store) | WARN |
| SCHEMA | main | hr_confidential | hr_confidential | schema tag | sensitive-tagged schema (namespace flagged sensitive) | WARN |
(?i)(pii|sensitiv|confidential|gdpr|personal|secret|restricted)) — tune it in the SQL to your tag vocabulary; it will miss org-specific tag names and can false-positive on unrelated ones.system.information_schema is privilege-aware, so volumes/schemas the principal can’t see are absent.storage_location is deliberately not emitted here (see access_volumes_inventory) — this is the finding view.For each sensitive volume, lock down access via grants and storage credentials — files can’t be masked — and confirm the data belongs in files rather than a governed table (free/config); tag or classify the untagged volumes so they stop being blind spots (config); and migrate any PII that should be maskable into governed UC tables (spend/process). Cross to the full volume inventory this is built from, the table-column PII it doesn’t cover, and the account’s tag vocabulary.
Feeds into: the file/schema-level PII findings (sensitive-tagged or untagged volumes + sensitive schemas, banded worst-first) — the blind-spot counterpart to the column-level access_classified_unmasked, drawn from the volume inventory