Crosshire
← Handbook
GovernEvidence · reviews · limits

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.

Stand 29.07.2026

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

What
Unity Catalog writes access and administration events to 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.
Three columns carry most of the weight: 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.
sql
Orientation query — what has this principal been doing in dwh_prod
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;
Three uses, three shapes, three different people asking
UseThe questionQuery shapeWho asks
Access reviewWho could read personal data last quarter, and who did?90 days, grouped by principal and tabledata protection officer, internal audit
Incident forensicsBetween 14:00 and 16:00 that day, who touched dim_customer?narrow window, full detail, no aggregationwhoever is running the incident
GDPR evidenceShow that access to email and phone is restricted and reviewed.the review query, plus the fact that it runs on a schedulethe customer's lawyer, eventually
Why here
This prevents discovering, mid-incident, that you have opinions instead of records. Somebody exported a customer list; the question is who, and the honest answer is a shrug and a fortnight of interviews.
The slower failure is more common in DACH projects: a data protection officer asks for evidence that access to 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.
Embedding it
  • Give the team the incident question, not the query: 'someone exported customer data at 15:00 last Tuesday — find them.' Let them discover which action_name they need.
  • Have whoever will field the DPO's request write the review query, so the person under time pressure later already built the tool.
  • When they hand you a query with no response.status_code, ask what a denied attempt looks like in their result set. Denials are the interesting rows.
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

What
A review that lists tables is theatre. The one worth running answers a narrower question: for every column classified as personal data, which principals actually touched the table carrying it, and when did they stop? The tags are already in Unity Catalog, so this is one join rather than a spreadsheet.
sql
90-day access review over PII-tagged tables in dwh_prod
-- 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;
Two columns make this a review rather than a report. 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.
Why here
Grants accumulate. Somebody needed 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'.
Driving the review off classification tags rather than a table list is what makes it survive: a new table carrying an 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.
Embedding it
  • Hand the quarterly run to the client with a calendar entry and a named deputy. A review that happens only when the consultant is on site stops at handover.
  • Have them present the first review to their own DPO with you in the room but silent. What the DPO pushes back on is the real requirement.
  • Ask what a person who left the company should look like in this output, then let them check whether that is true today. It usually is not.
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 is pii and pii_tables returns 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

What
A quarterly review catches an over-broad grant three months late; an alert catches it the same afternoon. Production permission changes are rare enough that every one can be read by a human, which makes this one of the few alerts in the warehouse that can safely fire on any row at all.
sql
Permission changes on dwh_prod — alert when this returns any row
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;
The 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.
Why here
The realistic scenario is not malice. It is a Friday afternoon, an urgent report, and someone with metastore admin granting 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.
Caught the same afternoon that is a two-minute conversation. Caught at the quarterly review it is a documented three-month exposure of personal data — a materially different conversation with a regulator.
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.
Embedding it
  • Let the team make the test grant and time the alert themselves. They learn the latency and trust the control because they watched it work.
  • Agree the response in advance — revert, or ratify and write it down — so the first real alert is not a debate about whose job it is.
  • Ask who is allowed to grant on dwh_prod at all. The honest answer is usually 'about six people', and narrowing that beats any alert.
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

What
Unity Catalog captures lineage automatically for work done on the platform, at two granularities. 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.
sql
Where does gold.fct_order_line.amount_eur come from?
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;
The expected result is three sources — 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.
Table lineage answers the other direction, and it is the query to run before touching a silver schema. Same table, read from source to target instead of target to source:
sql
Impact analysis — what consumes silver.sales_order_line?
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;
Do not filter out the rows where 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.
Lineage stops at the edge of Databricks unless you extend it. External lineage is now generally available, and here it is the difference between a graph that starts 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.
Why here
Two questions arrive repeatedly and one graph answers both. From the business: 'the revenue figure in this report is wrong — where does it come from?' From the auditor: 'demonstrate that this number derives from the source system, and show what happens to it on the way.' Without lineage both are answered by an engineer reading code, which takes hours and is as accurate as their memory.
Impact analysis is the second query above — the same graph walked source-to-target. Before altering 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.
Embedding it
  • Next time the business challenges a number, hand over the keyboard and let the team run the query while the business person watches. They will not draw that diagram by hand again.
  • Add 'external lineage declared' to the definition of done for pipelines touching systems outside Databricks, so it is a build step rather than a governance clean-up.
  • Have them do the impact analysis for a change they are afraid of. Discovering only two things depend on a table is how a team stops fearing its own warehouse.
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

What
Three limits, all of which a client will eventually find. Say them first — this is the part of the route that protects you.
Retention is two regimes for one feature. The lineage system tables keep a rolling one-year window; Catalog Explorer and the lineage API retain lineage captured after 01.09.2024 indefinitely. A question spanning more than a year cannot be answered from 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.
Both lineage tables are a subset. Records are emitted only where lineage can be inferred. Work that writes through a path rather than a table name, or runs on an engine outside Unity Catalog, produces no row. Hence the sentence that has to survive into the client's own documentation: absence of lineage is not evidence of absence of a data flow.
Column lineage misses literals. A column filled with an explicit constant has no source column and contributes nothing to the graph. This warehouse hits it in one guaranteed place — the unknown member from business rule 7:
sql
The unknown member: every value is a literal, so this INSERT emits no column lineage
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'
);
Every fact line that failed to resolve to a customer points at that row. Ask lineage where 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.
Why here
The commercial risk is asymmetric. Lineage demos well, so a client will over-read it — and the moment they treat the graph as complete, every downstream decision inherits an assumption you did not correct.
The technical risk is the retention split. GDPR and financial-audit questions routinely span more than twelve months, and the system-table query returns rows for the part of the window it covers with nothing marking the rest as missing. A partial answer that looks complete is worse than an empty one.
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.
Embedding it
  • Show the unknown-member blind spot live: run the column-lineage query on dim_customer, then show the INSERT. The gap between the two lands in ninety seconds.
  • Have the team write the limits paragraph in their own documentation, in their own words. A limit they can explain is one they will not oversell to their own management.
  • Let them try to answer a question deliberately older than a year and be misled by the partial result once. That is what makes the retention split memorable.
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

What
Audit data records who did what, when, and from which IP address. In several European jurisdictions that is employee monitoring data, and in Germany it is the kind of thing a works council has a legitimate interest in. It is not a debugging convenience to be granted broadly because it happens to be read-only.
sql
Grant the audit schema deliberately — not the whole system catalog
-- 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`;
The split is the point. Lineage is a daily engineering tool and withholding it makes the team slower for no benefit. The audit log is evidence about people, and it belongs to a group accountable for handling it.
Why here
Granting 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.
The self-referential problem sharpens it: whoever reads the audit log can see who reviewed what and when. If that is the same group that issues grants, the review has no independence, and a regulator who notices will say so.
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.
Embedding it
  • Ask the team who should be able to see that a named colleague queried a table at 23:40 on a Sunday. The discomfort in the room is the argument; you do not have to make it.
  • Have them run the check on their own metastore and report the current holders of system access to their lead. Finding it themselves turns your policy into their finding.
  • Raise the works-council question early with the client's own people — in DACH engagements it is a schedule risk, not a footnote.
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.

Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register.