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
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
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+)
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
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
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.
SELECT table_catalog, table_schema, table_name, column_name,
mask_name, 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
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
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
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
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
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
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
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
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
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.
SELECT table_catalog, table_schema, table_name,
filter_name, 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
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
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
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