18Audit & lineage
Governance is half done when the grants are right. The other half is proving, months later and to someone who does not trust you, who read a customer's email address and which SAP table a number came from. This route decides what evidence the warehouse produces automatically, what has to be declared by hand, and — the part that gets consultants into trouble — where the evidence stops.
The audit log
Every Unity Catalog action is already being recorded. The work is not collecting it — it is turning it into three specific answers before someone urgently needs one of them.
#What system.access.audit records
system.access.audit with no agent and no exporter. Every securable resolution, permission change, table creation and drop lands there, attributed to a principal, an IP address and a request id.action_name, user_identity.email, and request_params — a MAP<STRING, STRING> whose keys differ per action. For a table read the key is full_name_arg; for a grant change it is securable_full_name plus changes. Those two shapes cover almost every warehouse question.SELECT event_time,
action_name,
request_params.full_name_arg AS securable,
response.status_code AS status,
source_ip_address
FROM system.access.audit
WHERE service_name = 'unityCatalog'
AND user_identity.email = 'sp-dwh-prod-loader'
AND event_date >= current_date() - INTERVAL 7 DAYS
ORDER BY event_time DESC
LIMIT 200;| Use | The question | Query shape | Who asks |
|---|---|---|---|
| Access review | Who could read personal data last quarter, and who did? | 90 days, grouped by principal and table | data protection officer, internal audit |
| Incident forensics | Between 14:00 and 16:00 that day, who touched dim_customer? | narrow window, full detail, no aggregation | whoever is running the incident |
| GDPR evidence | Show that access to email and phone is restricted and reviewed. | the review query, plus the fact that it runs on a schedule | the customer's lawyer, eventually |
gold.dim_customer.email is controlled, and the team sends a screenshot of the grants. A screenshot proves today. The question was about six months of yesterdays.Doing it
- Have a metastore admin enable the system schemas on day one, before there are tables to audit.
- Write the three queries once, save them as a dashboard, and hand over the dashboard — not SQL in a chat message.
- Measure delivery latency on the client's own workspace: events arrive in minutes, not seconds, and an alert assuming real time fires late and gets distrusted.
- Enable verbose audit logging if interactive notebook commands matter to their compliance story.
Defaults
- Enable system schemas at metastore setup, not when the first request arrives.
- Filter on event_date, not event_time — the table is laid out by date and the difference is a full scan.
- Always carry response.status_code; counting only successes hides every attempt correctly refused.
Gotchas
- Jobs are attributed to the service principal that runs them, not the human who triggered the run. A review over user_identity.email shows sp-dwh-prod-loader touching every PII table daily and no person anywhere — check identity_metadata before concluding no human read anything.
- Audit retention is a rolling window — 365 days at the time of writing (Stand 29.07.2026; re-read the system-table reference before putting a number in a concept document). A question spanning 'since the project started' hits that edge silently: rows come back, just not all of them, and nothing in the result says so.
- Filtering only on action_name = 'getTable' and calling it 'who read the data'. getTable is metadata resolution; credential-based reads appear under generateTemporaryTableCredential. Miss the second and the review understates reality.
- Looking here for query text. It is not here — statement text lives in query history, with different retention and, because WHERE clauses carry literals, arguably more sensitive content than the audit log itself.
#The standing access review
-- information_schema is PER CATALOG: this must be dwh_prod's, not system's.
WITH pii_tables AS (
SELECT DISTINCT
catalog_name || '.' || schema_name || '.' || table_name AS full_name
FROM dwh_prod.information_schema.column_tags
-- The governed tag is 'pii' with values email | phone | address (see the
-- Access & ABAC route). Match on the KEY, not on a value -- a value filter
-- silently drops any class you add later, and this query failing open looks
-- exactly like a clean review.
WHERE tag_name = 'pii'
),
touches AS (
SELECT user_identity.email AS principal,
request_params.full_name_arg AS full_name,
event_time,
response.status_code AS status
FROM system.access.audit
WHERE service_name = 'unityCatalog'
AND action_name IN ('getTable', 'generateTemporaryTableCredential')
AND event_date >= current_date() - INTERVAL 90 DAYS
)
SELECT t.principal,
t.full_name,
count_if(t.status = 200) AS allowed,
count_if(t.status <> 200) AS refused,
max(t.event_time) AS last_touch
FROM touches t
JOIN pii_tables p ON p.full_name = t.full_name
GROUP BY t.principal, t.full_name
ORDER BY allowed DESC;refused shows people trying doors that are correctly locked — usually a broken workflow, occasionally something worse. last_touch is the revocation list: a principal whose last touch was four months ago holds a grant nobody needs.gold.dim_customer for a one-off analysis in March, it was issued in a hurry, and it is still there in November because no process removes anything. Every one is an extra row in the answer to 'who could have seen this data'.email column joins the review the moment it is tagged, with nobody remembering anything. Same argument as tag-driven ABAC, applied to evidence instead of enforcement.Doing it
- Run it quarterly on a schedule and store the output — the record that a review happened is itself the deliverable.
- Work last_touch first: everything past 90 days is a revocation candidate, and revoking is the fastest measurable win in the governance workstream.
- Split service principals from humans before presenting; a DPO reading a list where sp-dwh-prod-loader has 40,000 events will not find the two rows that matter.
- Cross-check against the grants. A principal in the grants but never in the log is dead access; one in the log but not the grants means your tag coverage is wrong.
Defaults
- Drive the review off classification tags, never a hand-maintained table list.
- Report allowed and refused separately; refusals are the signal.
- Every review ends with a revocation list somebody executes that week.
Gotchas
- Reading the tags from system.information_schema. It exists, the query runs, and it describes the system catalog only — information_schema is per catalog, so pii_tables comes back empty, the JOIN removes every row, and the review reports that nobody touched personal data. An empty access review is the one result nobody questions.
- Reviewing tables while classifying columns. A table joins the review only if a column on it is tagged, so an untagged PII column is invisible to the review AND to any ABAC policy — one missing tag removes a table from both enforcement and evidence, silently.
- Filtering the review on a tag key or value that nothing actually carries. Write
tag_name = 'data_classification'when the governed tag ispiiandpii_tablesreturns nothing, the join eliminates every row, and the review reports that no one touched personal data. It is the same failure as the empty review above, with a cause that survives code review because the SQL is faultless. Governed tags with a declared value list are what stop the drift; a free-text tag will not. - Presenting event counts as 'how much data was read'. One event can be a full table scan and one a BI tool refreshing its schema cache. The count answers who and when, never how much.
- Reviewing dwh_prod only. Dev usually holds a copy of production data that nobody classified, and it is the least-governed catalog in the estate.
#Alerting on permission changes
SELECT event_time,
user_identity.email AS changed_by,
request_params.securable_type AS securable_type,
request_params.securable_full_name AS securable,
request_params.changes AS changes,
source_ip_address
FROM system.access.audit
WHERE service_name = 'unityCatalog'
AND action_name IN ('updatePermissions', 'updateSecurable',
'createCatalog', 'deleteCatalog')
AND event_date >= current_date() - INTERVAL 1 DAY
AND coalesce(request_params.securable_full_name, '') LIKE 'dwh_prod%'
ORDER BY event_time DESC;changes parameter carries added and removed privileges as JSON, so the alert body can say what was granted to whom rather than that something happened. That decides whether the alert is read or filtered into a folder.SELECT on dwh_prod.gold to account users to unblock a colleague. It works, the report ships, nobody mentions it, and the permissions concept you wrote is now fiction.Doing it
- Schedule it hourly as a Databricks SQL alert on 'returns more than zero rows', routed to the rota that takes pipeline failures.
- Put the changes payload in the alert body — an alert saying only 'permissions changed' gets acknowledged without being read.
- Suppress the deployment service principal by name, not by pattern, so CI grants are quiet and human grants are loud.
- Test it by making a harmless grant in production and watching the alert arrive. An untested alert is a belief.
Defaults
- Any row means alert. If that is intolerable, the problem is the change rate, not the alert.
- CI grants suppressed by principal name; human grants always alert.
- Route to a rota — never a shared mailbox, never the consultant's address.
- Close every alert with a revert or a written ratification. Nothing closes silently.
Gotchas
- Alerting on the whole metastore instead of dwh_prod. Dev and test grants change constantly, the channel is noise within a week, and the alert everyone muted is the one that would have caught the production grant.
- Assuming securable names always start with the catalog. Metastore-level and external-location changes carry no catalog prefix, so LIKE 'dwh_prod%' drops exactly the broadest changes anyone can make — run a second, unfiltered query for those.
- Treating audit latency as zero on a five-minute schedule. Events land minutes after the fact, so a tight window plus latency silently skips the change that happened on the boundary.
- Alerting without agreeing the response. The first alert produces a thread about ownership instead of a revert, and the second gets ignored because the first did nothing.
Lineage as evidence
Lineage answers what the audit log cannot: not who read this number, but where it came from. It is genuinely good, and it is a subset — both halves of that sentence have to reach the client.
#Table and column lineage
system.access.table_lineage answers 'what fed this table and which job wrote it'. system.access.column_lineage narrows it to a single column, which is what an auditor asking about a specific number actually needs.SELECT DISTINCT
source_table_full_name,
source_column_name,
entity_type,
entity_id
FROM system.access.column_lineage
WHERE target_table_full_name = 'dwh_prod.gold.fct_order_line'
AND target_column_name = 'amount_eur'
AND event_date >= current_date() - INTERVAL 90 DAYS
ORDER BY source_table_full_name;quantity and unit_price from silver.sales_order_line, plus the rate column from silver.fx_rate — which is business rule 2 read back out of the platform. A fourth source, or a missing one, means the implementation and the rule have diverged, found without anyone reading the transformation code.SELECT target_table_full_name AS downstream_table,
entity_type,
count(*) AS runs,
max(event_date) AS last_seen
FROM system.access.table_lineage
WHERE source_table_full_name = 'dwh_prod.silver.sales_order_line'
AND event_date >= current_date() - INTERVAL 90 DAYS
GROUP BY target_table_full_name, entity_type
ORDER BY last_seen DESC;downstream_table is NULL. A NULL target means the read did not write a table — a notebook, a dashboard or an ad-hoc query consumed it — and those are consumers that break just as loudly as a pipeline. entity_type is what tells you which kind you are looking at.bronze.sap_vbap and one that starts at the SAP system and ends at the Power BI report finance actually opens. It is not automatic: you register the external system as a metadata object and declare the relationship to the Unity Catalog securable.silver.sales_order_line, the set of downstream consumers is a fact rather than an estimate, which is what turns 'we think nothing depends on this' into a change somebody is willing to make.Doing it
- Answer the next 'where does this number come from' with the column-lineage query in front of the person asking, not a diagram drawn afterwards.
- Register external metadata objects for SAP and the Power BI semantic model and declare the relationships — half a day that shortens every later audit conversation.
- Run the impact query before any silver schema change and paste the result into the pull request.
- Compare the column lineage of amount_eur against business rule 2 once per release; a divergence there is a correctness bug no row-count test will catch.
Defaults
- Column lineage for correctness questions, table lineage for impact analysis — reaching for the wrong one wastes the meeting.
- External lineage declared for SAP and Power BI as part of the pipeline's definition of done.
- Filter on event_date in every lineage query; these tables get large and are laid out by date.
Gotchas
- Reading lineage as a static diagram. Rows are emitted per run, so a table refactored two months ago still shows its old sources inside the window — constrain event_date or you are looking at an architecture that no longer exists.
- Assuming external lineage appeared because the feature is GA. Nothing is declared until someone declares it, and a graph that stops at bronze in front of an auditor reads as 'we cannot trace to source'.
- Using lineage as the only impact analysis before a breaking change. It covers what ran inside Unity Catalog in the window; a monthly report that has not run yet this month is not in it, and it will break.
- Confusing entity_id with a job name. It is an identifier, not a label — join it out, or hand the client a column of opaque values.
#Where the evidence stops
system.access.table_lineage and can be answered in Catalog Explorer — a surprising sentence to say in a meeting, and much worse to discover mid-answer.INSERT INTO gold.dim_customer (
customer_sk, customer_id, customer_name, country,
customer_segment, _tech_valid_from, _tech_is_current
)
SELECT 'UNKNOWN',
'UNKNOWN',
'Unknown customer',
'UNKNOWN',
'UNKNOWN', -- NOT 'C'. A real segment value here folds every
-- unresolved line into a segment finance reports on.
TIMESTAMP '2020-01-01 00:00:00',
true
FROM (SELECT 1) AS seed -- a WHERE clause needs a relation
WHERE NOT EXISTS (
SELECT 1 FROM gold.dim_customer WHERE customer_sk = 'UNKNOWN'
);dim_customer.customer_name comes from and it answers 'SAP' — truthfully and incompletely, because one of its values is a hard-coded string in a load script, and that is exactly the value an auditor would most want explained.Doing it
- Write the retention split into the permissions concept, so the limit is documented before anyone needs it rather than confessed during an audit.
- For anything older than a year go to Catalog Explorer or the lineage API, and say in the answer which source you used.
- Grep the repository for literals written into gold columns; each is a blind spot, and the unknown member is the one you already know about.
- When lineage returns nothing for a table, read the code before reporting it as unused. Absence is a prompt to look, not a conclusion.
Defaults
- State limits in the same document as the findings, never in a footnote.
- System tables inside a year; Catalog Explorer or the API beyond it, with the source named in the answer.
- Never write 'no other consumers'. Write 'no other consumers appear in lineage for the last N days' — true, and still useful.
Gotchas
- The one-year window returning rows for the covered part of a longer question. Nothing marks the boundary, so the answer looks complete and understates the flows by however much history the question needed.
- Reporting 'no downstream consumers, safe to drop' from a lineage query. Anything reading through a path, from outside the metastore, or on a cadence longer than your window is invisible — and the report that breaks is always the quarterly one.
- Assuming column lineage covers derived constants. amount_eur traces cleanly and the UNKNOWN customer row does not, so the exact case an auditor asks about is the case with no lineage.
- Quoting the 01.09.2024 indefinite-retention date without checking which interface the client uses. It applies to Catalog Explorer and the API; the system tables are still a rolling year, and mixing them up in writing is a correction you have to issue later.
Guarding the evidence
The two schemas this route is built on hold different things: one is a working tool, the other is a record of what named people did. Granting them together is the mistake, and it is made in a single statement.
#The audit log is itself sensitive
-- Evidence: governance group only.
GRANT USE CATALOG ON CATALOG system TO `dwh-governance`;
GRANT USE SCHEMA ON SCHEMA system.access TO `dwh-governance`;
GRANT SELECT ON TABLE system.access.audit TO `dwh-governance`;
GRANT SELECT ON TABLE system.access.table_lineage TO `dwh-governance`;
GRANT SELECT ON TABLE system.access.column_lineage TO `dwh-governance`;
-- Working tool: engineers get lineage, and not audit.
GRANT USE CATALOG ON CATALOG system TO `dwh-engineers`;
GRANT USE SCHEMA ON SCHEMA system.access TO `dwh-engineers`;
GRANT SELECT ON TABLE system.access.table_lineage TO `dwh-engineers`;
GRANT SELECT ON TABLE system.access.column_lineage TO `dwh-engineers`;SELECT on the whole system catalog to the engineering group is a two-minute decision that hands out billing data, every principal's activity history, and — through query history — statement text containing whatever literals people typed into WHERE clauses. Some of those literals are customer email addresses.Doing it
- Grant system.access at table level to a governance group; never grant SELECT on the system catalog as a whole.
- Give engineers lineage and withhold audit — the working tool and the evidence have different audiences.
- Check who currently holds access to system.* before writing the permissions concept; it is usually broader than anyone believes.
- If retention beyond a year is a genuine requirement, agree an archive owned by compliance — evidence held by the audited team is not evidence.
Defaults
- Table-level grants on system.access, to groups, never to individuals.
- Lineage for engineers, audit for governance — two grants, two audiences.
- Whoever reviews access is not whoever issues grants.
Gotchas
- GRANT SELECT ON CATALOG system as a convenience. It includes billing, query history and audit in one statement, and the exposure surfaces later when someone reads a colleague's query text — a trust incident, not a technical one.
- The team that issues grants also owning the access review. Nothing technical breaks; the evidence just stops meaning anything, and only an external auditor notices.
- Exporting the audit log to a spreadsheet for a review meeting. It leaves Unity Catalog and the copy is ungoverned personal data with no expiry — the same dead end as any CSV export.
- Assuming system tables cannot leak because they are read-only. Read-only is what makes them trustworthy evidence; it says nothing about who should be reading them.
Straight to the documentation
The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.
- Audit log system table reference — the schema of system.access.audit, plus how to enable the schema — the week-one step that cannot be backfilled
- Audit log reference — event catalogue — the full list of action_name values per service; go here before guessing which action means 'read'
- Lineage system tables reference — the table_lineage and column_lineage schemas, and the one-year retention window
- Capture and view data lineage using Unity Catalog — read the limitations section specifically — it is the source for what the graph does not contain
- External lineage — how to declare SAP and Power BI so the graph reaches past the platform edge