09 · Auditing with ACCOUNT_USAGE & Access History¶
Every control in this level, whether roles, authentication, network policies or masking, needs evidence that it works and
is used. Snowflake records the evidence in the SNOWFLAKE database, mainly in the ACCOUNT_USAGE schema: who
logged in from where, every query, every grant, and, with Access History, which columns each query actually read. This
lesson covers how to use those views, and the one detail that makes or breaks a data-access audit.
ACCOUNT_USAGE vs INFORMATION_SCHEMA¶
From Snowflake's documentation:
SNOWFLAKE.ACCOUNT_USAGE |
each database's INFORMATION_SCHEMA |
|
|---|---|---|
| Scope | the whole account | one database (plus account-level table functions) |
| Includes dropped objects | yes (with a DELETED timestamp) |
no |
| Latency | 45 minutes to 3 hours, varies by view | none |
| Retention of history | 1 year | 7 days to 6 months, varies |
Use INFORMATION_SCHEMA for "what's happening right now" and ACCOUNT_USAGE for audits, trends and anything older than a
few days. For data you must keep longer than a year, copy the views into your own tables on a schedule (a task, Level 2 · 07).
Who can read the audit views¶
Grant the SNOWFLAKE database's built-in database roles, rather than ACCOUNTADMIN, to auditors and platform teams:
| Database role | Gives visibility into |
|---|---|
SNOWFLAKE.OBJECT_VIEWER |
object metadata |
SNOWFLAKE.USAGE_VIEWER |
historical usage (queries, metering) |
SNOWFLAKE.GOVERNANCE_VIEWER |
governance data (policies, tags, access history) |
SNOWFLAKE.SECURITY_VIEWER |
security data (logins, users, grants) |
USE ROLE ACCOUNTADMIN;
CREATE ROLE IF NOT EXISTS auditor;
GRANT DATABASE ROLE SNOWFLAKE.GOVERNANCE_VIEWER TO ROLE auditor;
GRANT DATABASE ROLE SNOWFLAKE.SECURITY_VIEWER TO ROLE auditor;
GRANT USAGE ON WAREHOUSE bi_wh TO ROLE auditor;
Sign-ins¶
SELECT event_timestamp, user_name, client_ip, reported_client_type,
first_authentication_factor, second_authentication_factor, is_success, error_message
FROM SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY
WHERE event_timestamp > DATEADD('day', -7, CURRENT_TIMESTAMP())
AND is_success = 'NO'
ORDER BY event_timestamp DESC;
Useful audits from this view: failed sign-ins per user and IP (password spraying), successful password sign-ins without a second factor (lesson 02's enforcement), and sign-ins from IPs outside your network rules (which shouldn't succeed if network policies are in place).
Grants and role changes¶
-- who holds ACCOUNTADMIN, directly
SELECT grantee_name AS user_name, created_on
FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_USERS
WHERE role = 'ACCOUNTADMIN' AND deleted_on IS NULL;
-- privileges granted in the last 7 days, by whom
SELECT created_on, privilege, granted_on, name, grantee_name, granted_by
FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES
WHERE created_on > DATEADD('day', -7, CURRENT_TIMESTAMP())
ORDER BY created_on DESC;
These are the inputs for an effective-access calculation like the rbac.py model in lesson 01: load them into its Account
instead of a hand-written script, and you get "who can read this table?" from the live account.
Access History: who read which columns¶
Access History (Enterprise Edition) records, for each query, which objects and columns it read and wrote. The
ACCESS_HISTORY view has, among others, these columns per query: query_id, query_start_time, user_name,
direct_objects_accessed, base_objects_accessed, objects_modified, policies_referenced, and parent/root query IDs for
queries run from procedures.
The two "accessed" columns are the important distinction. From the documentation's view example:
direct_objects_accessedlists the objects named in the query. For a query on a view, that's the view and its projected columns.base_objects_accessedlists the underlying tables and columns the data actually came from, including columns used only to filter.
An audit of "who read customers' emails?" that looks only at direct objects misses everyone who read them through a view. To show the difference, here are four invented access-history records in the documented JSON shape, with the same audit query run in DuckDB 1.5.6 locally. This is synthetic data, not Snowflake output:
"""Synthetic ACCESS_HISTORY rows in the documented JSON shape (objectName / objectDomain / columns[columnName]),
and the 'who read customers.email?' audit, run in DuckDB. The rows are invented test data, not Snowflake output."""
import json, duckdb
def obj(name, cols, domain="Table"):
return {"objectName": name, "objectDomain": domain, "columns": [{"columnName": c} for c in cols]}
rows = [
("q1", "2026-10-01 09:00", "ANA", [obj("SALES.MART.CUSTOMERS", ["CUSTOMER_ID", "COUNTRY"])],
[obj("SALES.MART.CUSTOMERS", ["CUSTOMER_ID", "COUNTRY"])]),
("q2", "2026-10-02 10:30", "BEN", [obj("SALES.MART.CUSTOMERS", ["EMAIL"])],
[obj("SALES.MART.CUSTOMERS", ["EMAIL"])]),
# CY reads a VIEW that exposes email: the view is the direct object, the table column is the base object
("q3", "2026-10-03 14:00", "CY", [obj("SALES.MART.CUSTOMER_CONTACTS", ["CONTACT_EMAIL"], "View")],
[obj("SALES.MART.CUSTOMERS", ["EMAIL", "CUSTOMER_ID"])]),
("q4", "2026-10-04 08:15", "DEE", [obj("SALES.MART.ORDERS", ["ORDER_ID", "AMOUNT"])],
[obj("SALES.MART.ORDERS", ["ORDER_ID", "AMOUNT"])]),
]
con = duckdb.connect()
con.sql("CREATE TABLE access_history (query_id VARCHAR, query_start_time TIMESTAMP, user_name VARCHAR, direct_objects_accessed JSON, base_objects_accessed JSON)")
con.executemany("INSERT INTO access_history VALUES (?,?,?,?,?)", [(q, t, u, json.dumps(d), json.dumps(b)) for q, t, u, d, b in rows])
for label, col in (("direct_objects_accessed", "direct_objects_accessed"), ("base_objects_accessed", "base_objects_accessed")):
print(f"who read SALES.MART.CUSTOMERS.EMAIL, judged by {label}:")
print(con.sql(f"""
SELECT DISTINCT user_name, query_id
FROM access_history,
UNNEST(CAST(json_extract({col}, '$[*]') AS JSON[])) AS o(obj),
UNNEST(CAST(json_extract(obj, '$.columns[*]') AS JSON[])) AS c(cj)
WHERE (obj->>'objectName') = 'SALES.MART.CUSTOMERS' AND (cj->>'columnName') = 'EMAIL'
ORDER BY query_id""").fetchall())
who read SALES.MART.CUSTOMERS.EMAIL, judged by direct_objects_accessed:
[('BEN', 'q2')]
who read SALES.MART.CUSTOMERS.EMAIL, judged by base_objects_accessed:
[('BEN', 'q2'), ('CY', 'q3')]
Judged by direct_objects_accessed, only Ben read the email column. Judged by base_objects_accessed, Cy did too, by
querying CUSTOMER_CONTACTS, a view whose CONTACT_EMAIL column comes from CUSTOMERS.EMAIL. The Snowflake version of the
correct audit:
SELECT DISTINCT ah.user_name, ah.query_id, ah.query_start_time
FROM SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY ah,
LATERAL FLATTEN(input => ah.base_objects_accessed) obj,
LATERAL FLATTEN(input => obj.value:"columns") col
WHERE obj.value:"objectName"::VARCHAR = 'SALES.MART.CUSTOMERS'
AND col.value:"columnName"::VARCHAR = 'EMAIL'
AND ah.query_start_time > DATEADD('day', -30, CURRENT_TIMESTAMP())
ORDER BY ah.query_start_time DESC;
Note the double FLATTEN (objects, then columns) from Level 1 · 07. policies_referenced shows whether a masking or row
access policy was applied. Combine it with the query above to separate users who saw masked emails from those who saw raw
ones, which is usually the real question.
Access History also powers lineage: objects_modified records which columns a statement wrote and from which source
columns, so you can trace, say, where data derived from EMAIL ended up.
Turning audits into alerts¶
Snowflake alerts run a condition on a schedule and act when it returns rows:
CREATE OR REPLACE ALERT security_db.network.too_many_failed_logins
WAREHOUSE = bi_wh
SCHEDULE = '60 MINUTE'
IF (EXISTS (
SELECT user_name FROM SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY
WHERE is_success = 'NO' AND event_timestamp > DATEADD('hour', -2, CURRENT_TIMESTAMP())
GROUP BY user_name HAVING COUNT(*) >= 10))
THEN
CALL SYSTEM$SEND_EMAIL('my_email_int', 'security@example.com',
'Snowflake: repeated failed sign-ins', 'Check LOGIN_HISTORY for the last 2 hours.');
ALTER ALERT security_db.network.too_many_failed_logins RESUME;
Because ACCOUNT_USAGE can lag by up to a few hours, set the alert's look-back window wider than its schedule (here 2
hours checked hourly), and accept that it's a detective control, not a real-time one.
How It Actually Works¶
ACCOUNT_USAGE views are shared to every account from Snowflake through the SNOWFLAKE database, which is itself a share
(lesson 07). The data is produced by Snowflake's services as they run your account: the authentication service writes login
events, the query service writes query and access records, the metadata store emits grant and object changes. These records
are processed into the views asynchronously, which is the source of the latency. Access History's column-level detail comes
from the compiler: when it resolves a query, including expanding views and applying policies, it knows exactly which base
columns contribute to each output and filter, and it records that resolution alongside the query.
Common mistakes¶
- Auditing reads with
direct_objects_accessedonly. It misses access through views. - Granting
ACCOUNTADMINto auditors. Use theSNOWFLAKEdatabase roles. - Real-time expectations from
ACCOUNT_USAGE. Latency is up to hours. - Relying on one year of retention for multi-year obligations. Archive the views yourself.
- Alerts whose look-back equals their schedule. Late-arriving records are missed. Overlap the windows.
- Not distinguishing masked from unmasked reads. Use
policies_referenced.
Exercise¶
- Create the
auditorrole with the two database roles and confirm it can queryLOGIN_HISTORYandACCESS_HISTORYbut not your business tables. - Create a view over
customersthat exposesemailunder another name, query it as an analyst, wait for the latency, and find that read with thebase_objects_accessedquery. - Extend the audit to report, per user, whether the email values they saw were masked (
policies_referenced). - Build a daily task that copies the previous day's
LOGIN_HISTORYandACCESS_HISTORYrows into an archive table, so you can keep them beyond one year.