SOX access evidence for financial tables, without screenshots
The short answer
Export the grant records your platforms keep, then rebuild every day of the audit period with a script you can run. A screenshot shows one day. The auditor asks about the whole period. The script lists who could read or change each financial table on each day, and names the days your exports cannot rebuild.
What you will have at the end
A Python script, financial_table_access.py, that reads CSV files and prints, for every financial table you name, three lists. It also writes them as access_population.csv, access_changes.csv and access_not_covered.csv.
- The period's access population. Every person, role or group that could read or change the table on any day of the period, how (direct, or through which role or group), the first and last day, and the number of days. The rows come with a row count and a SHA-256, so a later run on the same exports can be compared.
- The changes in the period. Every grant and revoke on those tables, and every person added to or removed from a role or group that holds access, with who made it where the platform records that.
- NOT COVERED. The days of the period that a platform's exports cannot rebuild: records that begin after the first day of the period, a day with no export, a platform you did not export. A day can also be NOT COVERED for one table only: a day its own export missed (one
SHOW GRANTSper Snowflake table, one export per Databricks catalog or Redshift database), or, in the history form, the days before the earliest grant on the table, which is how a dropped or replaced table looks. A day that is NOT COVERED is never read as a day when nobody had access.
The script reads files and writes only its own output files. It never connects to a platform and never changes one. It does not decide who should have had access, and it does not check approvals or leavers: for those, see the guide on SOX access evidence for Snowflake, Databricks and Redshift. It shows who the platforms' records say could reach each table, and a person reviews that. The same steps with more detail are in the full guide: get it by email.
Runs 1 to 4 use generated records: invented people with example.com addresses, laid out in the columns each platform documents. They are not a company's data and not a run on Snowflake, Databricks or Redshift. Runs 5 to 7 take real grant exports from our own Snowflake and Databricks sandbox accounts, after we made real grants and revokes there. Run 8 is the script on a reader's files, invented for the purpose.
Step 1. Name the financial tables and the period
You decide which tables are financial; the script has no default and stops if the list is empty. Write one row per table in financial_tables.csv with the columns platform, table_catalog, table_schema and table_name. If a view reads a financial table and people can reach the view, list the view too, and on Snowflake export its grants with SHOW GRANTS ON VIEW and granted_on = 'VIEW' in place of 'TABLE': the script follows grants on what you list, not what a view selects.
Then set the period at the top of the script: PERIOD_START and PERIOD_END, and AS_OF, the day you took the exports. The days of the period are counted from the first to the last, both included.
Step 2. Export the grants, in the form each platform allows
Who could read a table on a past day depends on what the platform keeps. The script reads two forms and you choose one for each platform in FORM.
history: dated grants. Snowflake's ACCOUNT_USAGE.GRANTS_TO_ROLES hasCREATED_ON,DELETED_ON,PRIVILEGE,GRANTED_ON,NAME,TABLE_CATALOG,TABLE_SCHEMA,GRANTEE_NAMEandGRANTED_BY;DELETED_ONis when the privilege was revoked, and the view does not contain grants on dropped objects (read 2026-11-06). The overview says “The retention period for these views is 1 year (365 days)” (read 2026-11-06), so the script'sHISTORY_DAYSfor Snowflake is 365. The view's latency can be up to 120 minutes, so take the export at least two hours after the last day of the period.snapshots: one export a day. Databricks' information_schema.table_privileges lists the privileges as they are now, with no dates, and says that users with theMANAGEprivilege see only their own grants there (read 2026-11-06). Amazon Redshift's SVV_RELATION_PRIVILEGES lists the permissions “explicitly granted” now, and is visible to superusers and users with SYSLOG ACCESS UNRESTRICTED (read 2026-11-06). So you save one export a day, each row stamped with its day, and the script rebuilds the days from them. Snowflake can be run this way too, withSHOW GRANTS, as Run 5 does.
These are the queries a reader runs. Each writes the columns the script's files need. We could not run the Snowflake ACCOUNT_USAGE queries or the Redshift queries: our Snowflake role cannot read ACCOUNT_USAGE (the refusal is in Run 5) and we had no Redshift account. They follow the linked documentation and are untested; check one row of each export against a grant you know. The Databricks table-level and owner queries, and the Snowflake SHOW GRANTS queries, are the ones Runs 5 and 6 ran, apart from lines added after those runs: the database-role lines, which we checked on their own in Step 3, and the holder_kind, member_kind and Snowflake SHARE lines, which no run has used.
SELECT 'snowflake' AS platform,
CASE granted_to WHEN 'DATABASE_ROLE' THEN table_catalog || '.' || grantee_name
WHEN 'SHARE' THEN 'SHARE ' || grantee_name ELSE grantee_name END AS holder,
CASE WHEN granted_to IN ('ROLE', 'DATABASE_ROLE') THEN 'role' WHEN granted_to = 'SHARE' THEN 'group' ELSE '' END AS holder_kind,
privilege,
table_catalog, table_schema, name AS table_name,
created_on::DATE AS start_date, deleted_on::DATE AS end_date, granted_by
FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES
WHERE granted_on = 'TABLE' -- every grantee: roles, database roles, application roles and shares
AND (deleted_on IS NULL OR deleted_on >= DATEADD(day, -365, CURRENT_DATE));
Snowflake, snapshot form -> grant_snapshots.csv (one SHOW GRANTS for each financial table, then this, as in Run 5; the database-role, SHARE and holder_kind lines came after Run 5, and only the database-role lines are checked below)
SHOW GRANTS ON TABLE <database>.<schema>.<table>;
SELECT 'snowflake' AS platform, CURRENT_DATE() AS snapshot_date,
CASE "granted_to" WHEN 'DATABASE_ROLE' THEN split_part("name", '.', 1) || '.' || "grantee_name"
WHEN 'SHARE' THEN 'SHARE ' || "grantee_name" ELSE "grantee_name" END AS holder,
CASE WHEN "granted_to" IN ('ROLE', 'DATABASE_ROLE') THEN 'role' WHEN "granted_to" = 'SHARE' THEN 'group' ELSE '' END AS holder_kind,
"privilege" AS privilege, split_part("name", '.', 1) AS table_catalog,
split_part("name", '.', 2) AS table_schema, split_part("name", '.', 3) AS table_name
FROM TABLE(RESULT_SCAN(LAST_QUERY_ID()))
WHERE "granted_on" = 'TABLE'; -- every grantee: roles, database roles, application roles and shares
Databricks -> grant_snapshots.csv (once a day, once for each catalog)
SELECT 'databricks' AS platform, current_date() AS snapshot_date, grantee AS holder,
CASE WHEN grantee LIKE '%@%' THEN 'user' WHEN grantee RLIKE '^[0-9a-f-]{36}$' THEN 'service_account' ELSE 'group' END AS holder_kind, -- a user is an email, a service principal its application id
privilege_type AS privilege, table_catalog, table_schema, table_name
FROM <catalog>.information_schema.table_privileges
UNION ALL -- the owner is not in table_privileges: the ownership page says owners hold all privileges
SELECT 'databricks', current_date(), table_owner, CASE WHEN table_owner LIKE '%@%' THEN 'user' WHEN table_owner RLIKE '^[0-9a-f-]{36}$' THEN 'service_account' ELSE 'group' END, 'OWNERSHIP', table_catalog, table_schema, table_name
FROM <catalog>.information_schema.tables
UNION ALL -- grants on a schema or a catalog apply to the tables below them (blank schema or table)
SELECT 'databricks', current_date(), grantee, CASE WHEN grantee LIKE '%@%' THEN 'user' WHEN grantee RLIKE '^[0-9a-f-]{36}$' THEN 'service_account' ELSE 'group' END, privilege_type, catalog_name, schema_name, NULL
FROM <catalog>.information_schema.schema_privileges
UNION ALL
SELECT 'databricks', current_date(), grantee, CASE WHEN grantee LIKE '%@%' THEN 'user' WHEN grantee RLIKE '^[0-9a-f-]{36}$' THEN 'service_account' ELSE 'group' END, privilege_type, catalog_name, NULL, NULL
FROM <catalog>.information_schema.catalog_privileges;
Redshift -> grant_snapshots.csv (once a day, as a superuser, in each database)
SELECT 'redshift' AS platform, CURRENT_DATE AS snapshot_date, identity_name AS holder, identity_type AS holder_kind,
privilege_type AS privilege, current_database() AS table_catalog, namespace_name AS table_schema,
relation_name AS table_name
FROM svv_relation_privileges
UNION ALL
SELECT 'redshift', CURRENT_DATE, table_owner, 'user', 'OWNERSHIP', database_name, schema_name, table_name
FROM svv_redshift_tables
WHERE database_name = current_database(); -- this database only: each export then speaks for its own database
The Snowflake queries keep every grantee: a table shared to another account shows as SHARE and the share's name, and an application role is listed under its own name as an unmatched login, with no members exported for it. A share has no members these exports can list. In the snapshots form the table reads NOT COVERED on the days a share can reach it, directly or through a database role granted to the share. In the history form the report lists the share and says its people are not in the list. Either way the access population does not silently leave out the other account's users. The Databricks query sets holder_kind from the name: an email is a user, an application id is a service principal, and any other name is a group. Two facts shape what the exports from all three platforms can show. Databricks says “Object owners have all privileges on that object, including the ability to grant privileges to other principals” (read 2026-11-06), so the Databricks and Redshift queries add the owner as an OWNERSHIP row. Snowflake needs no extra row: ownership already shows as an OWNERSHIP grant. And a grant to Snowflake's PUBLIC role or Databricks' account users group reaches everyone: Snowflake calls PUBLIC a “Pseudo-role that is automatically granted to every user and every role in your account” (read 2026-11-06). The script shows such a holder as everyone and does not list names for it. The PUBLIC_HOLDERS setting names these holders.
Step 3. Export who is in each role or group
Access through a role is two intervals that overlap: the person in the role, and the role holding the privilege. The member files take an optional last column, member_kind, which the queries below fill: USER for a user, anything else for a role, group or share. The script never looks for members under a user, so a user and a role with the same name, such as a Snowflake role created for one user and named after that user, stay apart; without the column, a user and a role that share a name are read as one. Snowflake's GRANTS_TO_USERS has ROLE, GRANTEE_NAME, CREATED_ON and DELETED_ON (read 2026-11-06). A role granted to another role is a USAGE grant on the role in GRANTS_TO_ROLES: the privileges of a role “are inherited by any roles above that role in the hierarchy” (read 2026-11-06), and an OWNERSHIP grant on a role is not inheritance. Databricks group members come from the workspace's SCIM groups list, and Redshift's groups from pg_group (Groups (read 2026-11-06)). A Redshift role, which the grant export shows with holder_kind role, is not a group, so the pg_group member export lists none of the role's members. A financial table that a Redshift role can reach reads NOT COVERED on each day that member_snapshots.csv has no rows for that role. Adding the role's members to member_snapshots.csv for that day makes the day count. Neither Databricks nor Redshift has a view of past members. Databricks' audit log records addPrincipalToGroup and removePrincipalFromGroup events, which the script does not read, and Redshift's system logs keep about seven days. So the script reads one member export a day for them, the same as the grants.
SELECT 'snowflake' AS platform,
CASE WHEN granted_on = 'DATABASE_ROLE' THEN table_catalog || '.' || name ELSE name END AS holder,
CASE granted_to WHEN 'DATABASE_ROLE' THEN table_catalog || '.' || grantee_name
WHEN 'SHARE' THEN 'SHARE ' || grantee_name ELSE grantee_name END AS member,
created_on::DATE AS start_date, deleted_on::DATE AS end_date, granted_by, granted_to AS member_kind
FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES
WHERE granted_on IN ('ROLE', 'DATABASE_ROLE') AND granted_to IN ('ROLE', 'DATABASE_ROLE', 'SHARE')
AND privilege = 'USAGE' -- a share that holds a database role is kept, named as in the grant export
UNION ALL
SELECT 'snowflake', role, grantee_name, created_on::DATE, deleted_on::DATE, granted_by, 'USER'
FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_USERS;
Snowflake, snapshot form -> member_snapshots.csv (one SHOW GRANTS OF ROLE for every role, as SHOW ROLES lists them, and one SHOW GRANTS OF DATABASE ROLE for every database role, as SHOW DATABASE ROLES IN DATABASE lists them, then this)
SHOW GRANTS OF ROLE <role>; -- or: SHOW GRANTS OF DATABASE ROLE <database>.<database role>;
SELECT 'snowflake' AS platform, CURRENT_DATE() AS snapshot_date, "role" AS holder,
CASE "granted_to" WHEN 'DATABASE_ROLE' THEN split_part("role", '.', 1) || '.' || "grantee_name"
WHEN 'SHARE' THEN 'SHARE ' || "grantee_name" ELSE "grantee_name" END AS member,
"granted_to" AS member_kind
FROM TABLE(RESULT_SCAN(LAST_QUERY_ID()))
WHERE "granted_to" IN ('USER', 'ROLE', 'DATABASE_ROLE', 'SHARE')
UNION ALL -- one marker row for each role and day: a role with no members returns no rows
SELECT 'snowflake', CURRENT_DATE(), '<role, or database.database role>', '-', '';
A Snowflake database role is a role too, and it can hold a privilege on a financial table. The two exports name it differently: a table's grant list gives the database role without its database, and the role's own member list gives it with the database. So the queries above write it as the database name, a dot and the role name, such as CONTENT_LOOP_SANDBOX.LEDGER_READER, in both. We checked the database-role branches of these CASE expressions (not the SHARE branches, which no run has used) on our Snowflake sandbox on 2026-11-06, with a database role that held SELECT on a table and was granted to our role. The check's SELECT shows granted_to in place of snapshot_date, and our log shortens long statements:
> SHOW GRANTS ON TABLE CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_DBROLE.LEDGER;
created_on | privilege | granted_on | name | granted_to | grantee_name | grant_option | granted_by | granted_by_role_type
---------------------------------+-----------+------------+-----------------------------------------------------+---------------+-------------------+--------------+-------------------+---------------------
2026-11-06 00:58:55.409000-08:00 | OWNERSHIP | TABLE | CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_DBROLE.LEDGER | ROLE | CONTENT_LOOP_ROLE | true | CONTENT_LOOP_ROLE | ROLE
2026-11-06 00:58:56.078000-08:00 | SELECT | TABLE | CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_DBROLE.LEDGER | DATABASE_ROLE | LEDGER_READER | false | CONTENT_LOOP_ROLE | ROLE
> SELECT 'snowflake' AS platform, CASE WHEN "granted_to" = 'DATABASE_ROLE' THEN split_part("name", '.', 1) || '.' || "grantee_name" ELSE "grantee_name" END AS hol
PLATFORM | HOLDER | granted_to | PRIVILEGE | TABLE_NAME
----------+------------------------------------+---------------+-----------+-----------
snowflake | CONTENT_LOOP_ROLE | ROLE | OWNERSHIP | LEDGER
snowflake | CONTENT_LOOP_SANDBOX.LEDGER_READER | DATABASE_ROLE | SELECT | LEDGER
> SHOW GRANTS OF DATABASE ROLE CONTENT_LOOP_SANDBOX.LEDGER_READER;
created_on | role | granted_to | grantee_name | granted_by
---------------------------------+------------------------------------+------------+-------------------+------------------
2026-11-06 00:58:56.356000-08:00 | CONTENT_LOOP_SANDBOX.LEDGER_READER | ROLE | CONTENT_LOOP_ROLE | CONTENT_LOOP_ROLE
> SELECT 'snowflake' AS platform, "role" AS holder, CASE WHEN "granted_to" = 'DATABASE_ROLE' THEN split_part("role", '.', 1) || '.' || "grantee_name" ELSE "grante
PLATFORM | HOLDER | MEMBER
----------+------------------------------------+------------------
snowflake | CONTENT_LOOP_SANDBOX.LEDGER_READER | CONTENT_LOOP_ROLE
Redshift -> member_snapshots.csv (once a day)
SELECT 'redshift' AS platform, CURRENT_DATE AS snapshot_date, g.groname AS holder, u.usename AS member,
'USER' AS member_kind
FROM pg_user u JOIN pg_group g ON u.usesysid = ANY (g.grolist);
For Databricks, write one row a day for each member of each group, from the SCIM groups list, in the same columns: member_kind USER for a user or a service principal, and GROUP for a group inside the group. We did not run the Redshift query, and Redshift's role members (as opposed to groups) are not in it.
Step 4. Say which login is which person
logins.csv has the columns platform, login and email. A person who has a login on two platforms gets one row for each, with the same email, so the script counts them once. A login with no row is not hidden: it is printed as an unmatched login with the number of financial tables it could reach. Service accounts usually appear there, and that is a finding for a person to look at, not an error. A login whose grants have no holder_kind and whose name no member row uses is also flagged: if it is a group, export its members. On Snowflake, a member that was never exported as a role is flagged the same way: if it is a role, export its members. A Snowflake role whose export found nobody, only its marker row, is printed as exported with no members. In the snapshots form, a platform with no member rows at all reads NOT COVERED on every day when a grant on a financial table names a role or group, or a holder with no holder_kind that is not in logins.csv. Grants to an everyone holder such as account users do not count. Once that platform has member rows, any day without a member export reads NOT COVERED.
Step 5. Let the input checks run first
Before it rebuilds one day, the script runs its input checks: that the period and as-of dates are real, that every file and column is there, that every date is written YYYY-MM-DD, that no record ends before it starts or is dated after the day you took the exports, that every platform and holder kind is one the script knows, that no key column is blank, that a grant naming a table also names its schema, that every financial table and login is listed once, that every financial table appears in at least one grant record, and that every privilege on a financial table is one the script has been told about. If one fails, the run says INCOMPLETE and computes nothing, because a list built on files it could not read would look complete for the wrong reason. Runs 3 and 4 show this.
Step 6. Read the three lists
The script turns each grant into the days it was held, follows each role or group down to the people in it with the days they were in it (at most ten levels, so a loop ends), and keeps the days where both overlap. This is the query that does it, as it is in the download:
# via = the roles between the person and the grant, nearest the person first; empty for the holder itself
con.execute("""CREATE TEMP TABLE reach AS
WITH RECURSIVE r(pid, fno, platform, node, node_shown, s, e, depth, via) AS (
SELECT pm.pid, pm.fno, pm.platform, pm.holder, coalesce(h.shown, pm.holder), pm.start_d, pm.end_d, 0, ''
FROM pm LEFT JOIN shown h ON h.platform = pm.platform AND h.holder = pm.holder
WHERE pm.level IS NOT NULL AND pm.level <> 'NONE'
UNION ALL
SELECT r.pid, r.fno, r.platform, ed.member, ed.member_shown, greatest(r.s, ed.start_d), least(r.e, ed.end_d),
r.depth + 1, CASE WHEN r.depth = 0 THEN r.node_shown ELSE r.node_shown || ' > ' || r.via END
FROM r JOIN edge ed ON ed.platform = r.platform AND ed.holder = r.node
WHERE greatest(r.s, ed.start_d) <= least(r.e, ed.end_d) AND r.depth < 10)
SELECT * FROM r""")
A grant row names a table, a schema or a catalog; a grant on a schema or catalog counts for every financial table below it (Databricks' Unity Catalog inherits downward). A privilege counts as read (SELECT), change (INSERT, UPDATE, DELETE, TRUNCATE, MODIFY, ALTER, DROP, REBUILD) or both (OWNERSHIP, ALL PRIVILEGES, ALL); the lists are at the top of the script, so you can disagree with them. In the history form both end days count: a role granted on the 12th and revoked on the 20th was held on nine days. In the snapshots form a revoke is dated to the first export without the grant, and that day is not counted: Run 1's grant on 2026-09-10, revoked 2026-09-15, shows five days.
| List | What it says | What a person does with it |
|---|---|---|
| The access population | Each principal that could read or change the table on any day, how, the first and last day, and the number of days. | Compare it with who should have had access. A short run of days on a table that holds financial data is a question to ask: who asked for it, and who approved it. |
| The changes | Each grant, revoke, and addition to or removal from a role or group that holds access, with the day and who made it where the platform says. | Match each one with the request that led to it. A change with no request is a finding. |
| NOT COVERED | Days the exports cannot rebuild, with the reason. | Get the records for those days from another source, or write down that they are not available. Do not treat them as clean. |
Run 1: three things to find, planted
We ran the script on 2026-11-06 with DuckDB 1.5.6. --plant-error adds three things to the generated records: a role granted to an analyst for nine days in August, an everyone group granted read on a Databricks table for five days in September, and Redshift exports missing for the nine days before 2026-07-10 and for 2026-08-14. The script stops before planting if a planted name already occurs in the clean records, or if the Redshift days it removes are not the ones it expects.
python3 guides/who-could-access-financial-tables-during-the-audit-period/financial_table_access.py --demo --plant-error --out loop-out/out
Output from our run 1: the population header and the platforms
Population: 72 rows for 6 financial tables, period 2026-07-01 to 2026-09-30 (92 days), exports taken 2026-10-05, SHA-256 07493f7d6117a5fa... Platforms snowflake checked history: 92 of 92 days covered databricks checked snapshots: 92 of 92 days covered redshift checked snapshots: 82 of 92 days coveredOutput from our run 1: the access list, starting with the everyone group on a Databricks table
table principal kind access first day last day days how main.finance.gl_balances account users everyone read 2026-09-10 2026-09-14 5 direct (SELECT)Output from our run 1: a Snowflake table's lines, ending with the nine-day access
FIN_DB.LEDGER.JOURNAL_ENTRIES FARID login change 2026-07-01 2026-09-30 92 via FIN_POSTER (INSERT, UPDATE) FIN_DB.LEDGER.JOURNAL_ENTRIES FIN_OWNER role or group change 2026-07-01 2026-09-30 92 direct (OWNERSHIP) FIN_DB.LEDGER.JOURNAL_ENTRIES FIN_OWNER role or group read 2026-07-01 2026-09-30 92 direct (OWNERSHIP) FIN_DB.LEDGER.JOURNAL_ENTRIES FIN_POSTER role or group change 2026-07-01 2026-09-30 92 direct (INSERT, UPDATE) FIN_DB.LEDGER.JOURNAL_ENTRIES FIN_READER role or group read 2026-07-01 2026-09-30 92 direct (SELECT) FIN_DB.LEDGER.JOURNAL_ENTRIES GWEN login change 2026-08-12 2026-08-20 9 via FIN_POSTER (INSERT, UPDATE)Output from our run 1: the changes and the days NOT COVERED
2. Changes in the period (grants, revokes and people added to or removed from a role or group that holds access) 2026-07-10 MEMBER REMOVED snowflake FIN_DB.AP.AP_INVOICES AP_ANALYST LUCA by SECURITYADMIN (dated records) 2026-07-22 MEMBER ADDED snowflake FIN_DB.LEDGER.GL_BALANCES REPORTING IRIS by SECURITYADMIN (dated records) 2026-07-22 MEMBER ADDED snowflake FIN_DB.LEDGER.JOURNAL_ENTRIES REPORTING IRIS by SECURITYADMIN (dated records) 2026-08-03 GRANT snowflake FIN_DB.AP.AP_INVOICES REPORTING SELECT by SECURITYADMIN (dated records) 2026-08-06 MEMBER REMOVED databricks main.finance.gl_balances finance-readers hugo.reyes@example.com by not recorded (daily exports) 2026-08-06 MEMBER REMOVED databricks main.finance.revenue_recognition finance-readers hugo.reyes@example.com by not recorded (daily exports) 2026-08-12 MEMBER ADDED snowflake FIN_DB.LEDGER.JOURNAL_ENTRIES FIN_POSTER GWEN by SECURITYADMIN (dated records) 2026-08-20 MEMBER REMOVED snowflake FIN_DB.LEDGER.GL_BALANCES FIN_READER CHLOE by SECURITYADMIN (dated records) 2026-08-20 MEMBER REMOVED snowflake FIN_DB.LEDGER.JOURNAL_ENTRIES FIN_POSTER GWEN by SECURITYADMIN (dated records) 2026-08-20 MEMBER REMOVED snowflake FIN_DB.LEDGER.JOURNAL_ENTRIES FIN_READER CHLOE by SECURITYADMIN (dated records) 2026-08-31 REVOKE snowflake FIN_DB.AP.AP_INVOICES REPORTING SELECT by SECURITYADMIN (dated records) 2026-09-10 GRANT databricks main.finance.gl_balances account users SELECT by not recorded (daily exports) 2026-09-15 REVOKE databricks main.finance.gl_balances account users SELECT by not recorded (daily exports) 3. NOT COVERED (days these exports cannot rebuild; never read as nobody had access) redshift 2026-07-01 to 2026-07-09 (9 day(s)): before the first daily export (2026-07-10) redshift 2026-08-14 to 2026-08-14 (1 day(s)): no export for this day 6 financial tables, 72 access rows, 13 changes, 10 platform-day(s) NOT COVERED. Evidence written to loop-out/out/: access_population-planted-error.csv (72 rows), access_changes-planted-error.csv, access_not_covered-planted-error.csv.
The nine-day line is the one a screenshot misses: a person who could change the table from 2026-08-12 to 2026-08-20 through the role FIN_POSTER, and the changes list shows who added them and who removed them. The account users line shows the everyone group could read the table for five days. The Redshift days are listed as NOT COVERED, ten platform-days in all: nine before the first daily export and one with no export.
Run 2: the clean records
Command: run 2python3 guides/who-could-access-financial-tables-during-the-audit-period/financial_table_access.py --demo --out loop-out/out
Output from our run 2: the population header and the platforms
Population: 70 rows for 6 financial tables, period 2026-07-01 to 2026-09-30 (92 days), exports taken 2026-10-05, SHA-256 89064fb8c933ec8b... Platforms snowflake checked history: 92 of 92 days covered databricks checked snapshots: 92 of 92 days covered redshift checked snapshots: 92 of 92 days coveredOutput from our run 2: the changes and the days NOT COVERED
2. Changes in the period (grants, revokes and people added to or removed from a role or group that holds access) 2026-07-10 MEMBER REMOVED snowflake FIN_DB.AP.AP_INVOICES AP_ANALYST LUCA by SECURITYADMIN (dated records) 2026-07-22 MEMBER ADDED snowflake FIN_DB.LEDGER.GL_BALANCES REPORTING IRIS by SECURITYADMIN (dated records) 2026-07-22 MEMBER ADDED snowflake FIN_DB.LEDGER.JOURNAL_ENTRIES REPORTING IRIS by SECURITYADMIN (dated records) 2026-08-03 GRANT snowflake FIN_DB.AP.AP_INVOICES REPORTING SELECT by SECURITYADMIN (dated records) 2026-08-06 MEMBER REMOVED databricks main.finance.gl_balances finance-readers hugo.reyes@example.com by not recorded (daily exports) 2026-08-06 MEMBER REMOVED databricks main.finance.revenue_recognition finance-readers hugo.reyes@example.com by not recorded (daily exports) 2026-08-20 MEMBER REMOVED snowflake FIN_DB.LEDGER.GL_BALANCES FIN_READER CHLOE by SECURITYADMIN (dated records) 2026-08-20 MEMBER REMOVED snowflake FIN_DB.LEDGER.JOURNAL_ENTRIES FIN_READER CHLOE by SECURITYADMIN (dated records) 2026-08-31 REVOKE snowflake FIN_DB.AP.AP_INVOICES REPORTING SELECT by SECURITYADMIN (dated records) 3. NOT COVERED (days these exports cannot rebuild; never read as nobody had access) none 6 financial tables, 70 access rows, 9 changes, 0 platform-day(s) NOT COVERED. Evidence written to loop-out/out/: access_population.csv (70 rows), access_changes.csv, access_not_covered.csv.
Every day is covered. The population is the same rows on every run of the same exports: we ran the clean demo three times and the SHA-256 was the same each time. A clean run still lists the people who had access; it is clean in that no input check failed and no day is NOT COVERED.
Run 3: the input checks stop a bad export
--plant-input plants one fault for each input check that a file can trip: a start date not written YYYY-MM-DD, a role membership that ends before it starts, a platform not in PLATFORMS, a privilege the script does not know, a blank holder, a grant that names a table and no schema, a financial table listed twice, a financial table no grant record names, one login with two emails, a snapshot dated after the exports were taken and a holder kind the script does not know.
python3 guides/who-could-access-financial-tables-during-the-audit-period/financial_table_access.py --demo --plant-input --out loop-out/out
Output from our run 3
Input checks
[ok] PERIOD_START, PERIOD_END and AS_OF are real dates written YYYY-MM-DD
[ok] the period ends on or after the day it starts
[ok] PLATFORMS lists the platforms you exported, from snowflake, databricks, redshift
[ok] FORM names history or snapshots for every platform in PLATFORMS
[ok] HISTORY_DAYS gives a number of days for every history platform
[ok] every file you need is there with every column the script reads
read 8 rows from financial_tables.csv
read 16 rows from grants.csv
read 13 rows from members.csv
read 922 rows from grant_snapshots.csv
read 588 rows from member_snapshots.csv
read 31 rows from logins.csv
[FAIL] every start date, end date and snapshot date is a real date written YYYY-MM-DD (an end date may be empty): 1 value(s) empty where a date is needed, or not written YYYY-MM-DD
[FAIL] no record ends before it starts: 1 record(s) with an end date before the start date
[FAIL] no snapshot date or start date is after AS_OF: 1 record(s) dated after 2026-10-05
[FAIL] every platform value is one of PLATFORMS: unlisted platforms: grants.csv: 1
[FAIL] every holder_kind is empty or one of user, role, group, public, service_account: 1 row(s) with another kind
[FAIL] no key column is blank (platform, holder, member, privilege, catalog, table, login, email): 1 row(s) with a blank key column
[FAIL] a grant that names a table also names its schema: 1 row(s) with a table and no schema
[FAIL] every financial table is listed once: 1 row(s) repeat a table
[ok] at least one financial table is listed
[FAIL] each login is listed once per platform: 1 login row(s) repeat a login
[FAIL] every financial table appears in at least one grant record: 1 financial table(s) no grant record names: a stale list, a wrong catalog or schema, or an export that cannot see them
[FAIL] every privilege on a financial table is one the script knows (READ, CHANGE, BOTH or IGNORED lists): 1 grant(s) on a financial table with a privilege in none of the four lists at the top of the script
INCOMPLETE: 11 input check(s) failed, so nothing is computed. Fix the input.
Run 4: a renamed column stops the run
Command: run 4python3 guides/who-could-access-financial-tables-during-the-audit-period/financial_table_access.py --demo --plant-header --out loop-out/out
Output from our run 4
Input checks
[ok] PERIOD_START, PERIOD_END and AS_OF are real dates written YYYY-MM-DD
[ok] the period ends on or after the day it starts
[ok] PLATFORMS lists the platforms you exported, from snowflake, databricks, redshift
[ok] FORM names history or snapshots for every platform in PLATFORMS
[ok] HISTORY_DAYS gives a number of days for every history platform
[FAIL] every file you need is there with every column the script reads: members.csv has no column holder
read 6 rows from financial_tables.csv
read 12 rows from grants.csv
read 0 rows from members.csv
read 920 rows from grant_snapshots.csv
read 588 rows from member_snapshots.csv
read 30 rows from logins.csv
INCOMPLETE: 1 input check(s) failed, so nothing is computed. Fix the input.
Runs 5 to 7: real exports from a Snowflake and a Databricks account
On 2026-11-06 we created three tables in a schema in a Snowflake account and three in a Databricks workspace: two we treat as financial and one that is not. On each platform we took an export, made two real grants, took a second export, made one real revoke, and took a third. The exports are real; the dates are not. The three exports were taken minutes apart, and we wrote 2026-11-04, 2026-11-05 and 2026-11-06 in snapshot_date, so each stands for a daily export. Both schemas were dropped afterwards. Five things in what came back matter:
- Snowflake: the
ACCOUNT_USAGEform was refused, and so was creating an account role. Our role cannot readSNOWFLAKE.ACCOUNT_USAGEor create an account role (it can create a database role, as the check in Step 3 shows); the errors are below word for word. A reader whose role can readACCOUNT_USAGEneeds the history-form queries in Steps 2 and 3, which we have not run. So the real Snowflake events are grants toPUBLIC, which our role may make on its own tables, and the exports areSHOW GRANTS. - Snowflake: a revoked grant is gone from
SHOW GRANTS. After the revoke, the third export no longer lists it, and nothing in it says it was ever there. The script finds it by comparing exports.DELETED_ONinACCOUNT_USAGEis what dates a revoke without a daily export. - Databricks: the audit log was refused. Our principal cannot read
system.access.audit. The system tables page says an admin must grantUSE CATALOGon the system catalog andUSE SCHEMAandSELECTon the system schemas to anyone else, and that “The free retention period for supported system tables is 395 days” (read 2026-11-06). The script does not read the log: the audit log reference lists theupdatePermissionsevent's request parameters (securable_type,changes,securable_full_name,workspace_idandmetastore_id) but not what is insidechanges(read 2026-11-06), and we could not read a real event. - Databricks: the owner is a service principal, written as its id. The owner column returned a UUID, which the ownership page says is how a service principal is written (its applicationId value) (read 2026-11-06). It appears in the unmatched logins.
- Neither platform has a member list for the everyone holder.
PUBLICandaccount usersare inPUBLIC_HOLDERS, so the script shows them aseveryone.
Each export below ran SHOW GRANTS ON TABLE for the table (and SHOW GRANTS OF ROLE CONTENT_LOOP_ROLE for the members) just before the SELECT; our run log does not print those SHOW statements.
> CREATE ROLE FINANCE_AUDIT_READER ERROR: 003001 (42501): SQL access control error: Insufficient privileges to operate on account 'DATAMEANING'. Your primary role CONTENT_LOOP_ROLE must have CREATE ROLE granted on ACCOUNT DATAMEANING. > SELECT table_catalog, table_schema, table_name, grantee_name FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES LIMIT 1 ERROR: 002003 (02000): SQL compilation error: Schema 'SNOWFLAKE.ACCOUNT_USAGE' does not exist or not authorized. Your primary role CONTENT_LOOP_ROLE or one of your secondary roles must have USAGE or any other privilege granted on SCHEMA SNOWFLAKE.ACCOUNT_USAGE.Output from our run 5: Snowflake, after two real grants to PUBLIC, the second export and the members
> GRANT SELECT ON TABLE GL_BALANCES TO ROLE PUBLIC
status
--------------------------------
Statement executed successfully.
> GRANT SELECT ON TABLE AP_INVOICES TO ROLE PUBLIC
status
--------------------------------
Statement executed successfully.
> SELECT 'snowflake' AS platform, '2026-11-05' AS snapshot_date, "grantee_name" AS holder, 'role' AS holder_kind, "privilege" AS privilege, split_part("name", '.', 1) AS table_catalog, split_part("name", '.', 2) AS table_schema, split_part("name", '.', 3) AS table_name FROM TABLE(RESULT_SCAN(LAST_QUERY_ID())) WHERE "granted_on" = 'TABLE' AND "granted_to" = 'ROLE'
PLATFORM | SNAPSHOT_DATE | HOLDER | HOLDER_KIND | PRIVILEGE | TABLE_CATALOG | TABLE_SCHEMA | TABLE_NAME
----------+---------------+-------------------+-------------+-----------+----------------------+-----------------------------------------------------------+------------
snowflake | 2026-11-05 | CONTENT_LOOP_ROLE | role | OWNERSHIP | CONTENT_LOOP_SANDBOX | WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD | GL_BALANCES
snowflake | 2026-11-05 | PUBLIC | role | SELECT | CONTENT_LOOP_SANDBOX | WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD | GL_BALANCES
> SELECT 'snowflake' AS platform, '2026-11-05' AS snapshot_date, "grantee_name" AS holder, 'role' AS holder_kind, "privilege" AS privilege, split_part("name", '.', 1) AS table_catalog, split_part("name", '.', 2) AS table_schema, split_part("name", '.', 3) AS table_name FROM TABLE(RESULT_SCAN(LAST_QUERY_ID())) WHERE "granted_on" = 'TABLE' AND "granted_to" = 'ROLE'
PLATFORM | SNAPSHOT_DATE | HOLDER | HOLDER_KIND | PRIVILEGE | TABLE_CATALOG | TABLE_SCHEMA | TABLE_NAME
----------+---------------+-------------------+-------------+-----------+----------------------+-----------------------------------------------------------+------------
snowflake | 2026-11-05 | CONTENT_LOOP_ROLE | role | OWNERSHIP | CONTENT_LOOP_SANDBOX | WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD | AP_INVOICES
snowflake | 2026-11-05 | PUBLIC | role | SELECT | CONTENT_LOOP_SANDBOX | WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD | AP_INVOICES
> SELECT 'snowflake' AS platform, '2026-11-05' AS snapshot_date, "role" AS holder, "grantee_name" AS member FROM TABLE(RESULT_SCAN(LAST_QUERY_ID())) WHERE "granted_to" IN ('USER', 'ROLE')
PLATFORM | SNAPSHOT_DATE | HOLDER | MEMBER
----------+---------------+-------------------+--------------------
snowflake | 2026-11-05 | CONTENT_LOOP_ROLE | CONTENT_LOOP_RUNNER
Output from our run 5: Snowflake, after a real revoke, the third export (the revoked table has lost its PUBLIC row)
> REVOKE SELECT ON TABLE AP_INVOICES FROM ROLE PUBLIC
status
----------------------------------------------------
Statement executed successfully. 1 objects affected.
> SELECT 'snowflake' AS platform, '2026-11-06' AS snapshot_date, "grantee_name" AS holder, 'role' AS holder_kind, "privilege" AS privilege, split_part("name", '.', 1) AS table_catalog, split_part("name", '.', 2) AS table_schema, split_part("name", '.', 3) AS table_name FROM TABLE(RESULT_SCAN(LAST_QUERY_ID())) WHERE "granted_on" = 'TABLE' AND "granted_to" = 'ROLE'
PLATFORM | SNAPSHOT_DATE | HOLDER | HOLDER_KIND | PRIVILEGE | TABLE_CATALOG | TABLE_SCHEMA | TABLE_NAME
----------+---------------+-------------------+-------------+-----------+----------------------+-----------------------------------------------------------+------------
snowflake | 2026-11-06 | CONTENT_LOOP_ROLE | role | OWNERSHIP | CONTENT_LOOP_SANDBOX | WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD | GL_BALANCES
snowflake | 2026-11-06 | PUBLIC | role | SELECT | CONTENT_LOOP_SANDBOX | WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD | GL_BALANCES
> SELECT 'snowflake' AS platform, '2026-11-06' AS snapshot_date, "grantee_name" AS holder, 'role' AS holder_kind, "privilege" AS privilege, split_part("name", '.', 1) AS table_catalog, split_part("name", '.', 2) AS table_schema, split_part("name", '.', 3) AS table_name FROM TABLE(RESULT_SCAN(LAST_QUERY_ID())) WHERE "granted_on" = 'TABLE' AND "granted_to" = 'ROLE'
PLATFORM | SNAPSHOT_DATE | HOLDER | HOLDER_KIND | PRIVILEGE | TABLE_CATALOG | TABLE_SCHEMA | TABLE_NAME
----------+---------------+-------------------+-------------+-----------+----------------------+-----------------------------------------------------------+------------
snowflake | 2026-11-06 | CONTENT_LOOP_ROLE | role | OWNERSHIP | CONTENT_LOOP_SANDBOX | WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD | AP_INVOICES
Output from our run 6: Databricks refuses the audit log
> SELECT event_time, action_name FROM system.access.audit WHERE action_name = 'updatePermissions' LIMIT 1 ERROR: [INSUFFICIENT_PERMISSIONS] Insufficient privileges:Output from our run 6: Databricks, after two real grants to account users, the second export and the owner rows
> GRANT SELECT ON TABLE content_loop.who_could_access_financial_tables_during_the_audit_period.gl_balances TO `account users`
(done)
> GRANT SELECT ON TABLE content_loop.who_could_access_financial_tables_during_the_audit_period.revenue_recognition TO `account users`
(done)
> SELECT 'databricks' AS platform, '2026-11-05' AS snapshot_date, grantee AS holder, '' AS holder_kind, privilege_type AS privilege, table_catalog, table_schema, table_name FROM content_loop.information_schema.table_privileges WHERE table_schema = 'who_could_access_financial_tables_during_the_audit_period' AND table_name IN ('gl_balances', 'revenue_recognition') ORDER BY table_name, grantee, privilege_type
platform | snapshot_date | holder | holder_kind | privilege | table_catalog | table_schema | table_name
-----------+---------------+---------------+-------------+-----------+---------------+-----------------------------------------------------------+--------------------
databricks | 2026-11-05 | account users | | SELECT | content_loop | who_could_access_financial_tables_during_the_audit_period | gl_balances
databricks | 2026-11-05 | account users | | SELECT | content_loop | who_could_access_financial_tables_during_the_audit_period | revenue_recognition
> SELECT 'databricks' AS platform, '2026-11-05' AS snapshot_date, table_owner AS holder, '' AS holder_kind, 'OWNERSHIP' AS privilege, table_catalog, table_schema, table_name FROM content_loop.information_schema.tables WHERE table_schema = 'who_could_access_financial_tables_during_the_audit_period' AND table_name IN ('gl_balances', 'revenue_recognition') ORDER BY table_name
platform | snapshot_date | holder | holder_kind | privilege | table_catalog | table_schema | table_name
-----------+---------------+--------------------------------------+-------------+-----------+---------------+-----------------------------------------------------------+--------------------
databricks | 2026-11-05 | 94d0d5fd-0163-4380-86f4-60838753527e | | OWNERSHIP | content_loop | who_could_access_financial_tables_during_the_audit_period | gl_balances
databricks | 2026-11-05 | 94d0d5fd-0163-4380-86f4-60838753527e | | OWNERSHIP | content_loop | who_could_access_financial_tables_during_the_audit_period | revenue_recognition
Output from our run 6: Databricks, after a real revoke, the third export
> REVOKE SELECT ON TABLE content_loop.who_could_access_financial_tables_during_the_audit_period.revenue_recognition FROM `account users`
(done)
> SELECT 'databricks' AS platform, '2026-11-06' AS snapshot_date, grantee AS holder, '' AS holder_kind, privilege_type AS privilege, table_catalog, table_schema, table_name FROM content_loop.information_schema.table_privileges WHERE table_schema = 'who_could_access_financial_tables_during_the_audit_period' AND table_name IN ('gl_balances', 'revenue_recognition') ORDER BY table_name, grantee, privilege_type
platform | snapshot_date | holder | holder_kind | privilege | table_catalog | table_schema | table_name
-----------+---------------+---------------+-------------+-----------+---------------+-----------------------------------------------------------+------------
databricks | 2026-11-06 | account users | | SELECT | content_loop | who_could_access_financial_tables_during_the_audit_period | gl_balances
We saved the rows those queries returned as the files the script reads, set the period to 2026-11-04 to 2026-11-06, the platforms to Snowflake and Databricks, and FORM to snapshots for both, and ran it. Run 6 ran before the Databricks query filled holder_kind, so we filled it in the saved rows the way the Databricks grant query above (the one a reader runs) does: the owner's application id is service_account and account users is group. The login CONTENT_LOOP_RUNNER was mapped to an invented email address, and no HR or other file was used.
The download, with only the settings at the top changed:
Command: run 7, the script on those exportspython3 loop-out/real/financial_table_access.py --out loop-out/real/out
Output from our run 7: the script on the real exports
Population: 16 rows for 4 financial tables, period 2026-11-04 to 2026-11-06 (3 days), exports taken 2026-11-06, SHA-256 0fbb38430af87977... Platforms snowflake checked snapshots: 3 of 3 days covered databricks checked snapshots: 3 of 3 days covered 1. Who could read or change each financial table (principals with a person's email where logins.csv gives one) table principal kind access first day last day days how content_loop.who_could_access_financial_tables_during_the_audit_period.gl_balances 94d0d5fd-0163-4380-86f4-60838753527e login change 2026-11-04 2026-11-06 3 direct (OWNERSHIP) content_loop.who_could_access_financial_tables_during_the_audit_period.gl_balances 94d0d5fd-0163-4380-86f4-60838753527e login read 2026-11-04 2026-11-06 3 direct (OWNERSHIP) content_loop.who_could_access_financial_tables_during_the_audit_period.gl_balances account users everyone read 2026-11-05 2026-11-06 2 direct (SELECT) content_loop.who_could_access_financial_tables_during_the_audit_period.revenue_recognition 94d0d5fd-0163-4380-86f4-60838753527e login change 2026-11-04 2026-11-06 3 direct (OWNERSHIP) content_loop.who_could_access_financial_tables_during_the_audit_period.revenue_recognition 94d0d5fd-0163-4380-86f4-60838753527e login read 2026-11-04 2026-11-06 3 direct (OWNERSHIP) content_loop.who_could_access_financial_tables_during_the_audit_period.revenue_recognition account users everyone read 2026-11-05 2026-11-05 1 direct (SELECT) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.AP_INVOICES CONTENT_LOOP_ROLE role or group change 2026-11-04 2026-11-06 3 direct (OWNERSHIP) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.AP_INVOICES CONTENT_LOOP_ROLE role or group read 2026-11-04 2026-11-06 3 direct (OWNERSHIP) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.AP_INVOICES CONTENT_LOOP_RUNNER login change 2026-11-04 2026-11-06 3 via CONTENT_LOOP_ROLE (OWNERSHIP) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.AP_INVOICES CONTENT_LOOP_RUNNER login read 2026-11-04 2026-11-06 3 via CONTENT_LOOP_ROLE (OWNERSHIP) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.AP_INVOICES PUBLIC everyone read 2026-11-05 2026-11-05 1 direct (SELECT) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.GL_BALANCES CONTENT_LOOP_ROLE role or group change 2026-11-04 2026-11-06 3 direct (OWNERSHIP) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.GL_BALANCES CONTENT_LOOP_ROLE role or group read 2026-11-04 2026-11-06 3 direct (OWNERSHIP) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.GL_BALANCES CONTENT_LOOP_RUNNER login change 2026-11-04 2026-11-06 3 via CONTENT_LOOP_ROLE (OWNERSHIP) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.GL_BALANCES CONTENT_LOOP_RUNNER login read 2026-11-04 2026-11-06 3 via CONTENT_LOOP_ROLE (OWNERSHIP) CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.GL_BALANCES PUBLIC everyone read 2026-11-05 2026-11-06 2 direct (SELECT) 1 people matched by email; 1 login(s) with no email in logins.csv; 1 role(s) or group(s) listed. unmatched login databricks 94d0d5fd-0163-4380-86f4-60838753527e (2 financial table(s)) 2. Changes in the period (grants, revokes and people added to or removed from a role or group that holds access) 2026-11-05 GRANT databricks content_loop.who_could_access_financial_tables_during_the_audit_period.gl_balances account users SELECT by not recorded (daily exports) 2026-11-05 GRANT databricks content_loop.who_could_access_financial_tables_during_the_audit_period.revenue_recognition account users SELECT by not recorded (daily exports) 2026-11-05 GRANT snowflake CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.AP_INVOICES PUBLIC SELECT by not recorded (daily exports) 2026-11-05 GRANT snowflake CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.GL_BALANCES PUBLIC SELECT by not recorded (daily exports) 2026-11-06 REVOKE databricks content_loop.who_could_access_financial_tables_during_the_audit_period.revenue_recognition account users SELECT by not recorded (daily exports) 2026-11-06 REVOKE snowflake CONTENT_LOOP_SANDBOX.WHO_COULD_ACCESS_FINANCIAL_TABLES_DURING_THE_AUDIT_PERIOD.AP_INVOICES PUBLIC SELECT by not recorded (daily exports) 3. NOT COVERED (days these exports cannot rebuild; never read as nobody had access) none 4 financial tables, 16 access rows, 6 changes, 0 platform-day(s) NOT COVERED. Evidence written to loop-out/real/out/: access_population.csv (16 rows), access_changes.csv, access_not_covered.csv.
In Run 7, the two grants to everyone show as one day on a table whose grant was revoked and two on a table whose grant was kept, and the grant and revoke show in the changes list. The changes list says the maker is not recorded, because a daily export has no maker; a history export does (GRANTED_BY).
How to read the results
- A row is a day count, not an event. The first and last day are the first and last day in the period on which the records say the principal could reach the table; the days in between need not all be held, so read the number of days next to them.
- Access is an upper bound for what the exported grants allow. The script counts a privilege on the table, or on its schema or catalog. It does not check the other privileges a platform needs before a read works (for example
USAGEon the database and schema in Snowflake, orUSE CATALOGandUSE SCHEMAin Databricks). - A day that is NOT COVERED was not looked at. It is not a day when nobody had access, and a platform printed as NOT CHECKED had no rows exported.
- Keep the files with the date of the export. The result is as of
AS_OFand of the exports you took; the SHA-256 lets a later run on the same files be compared with this one. - Check the counts. The last line gives the tables read, the access rows, the changes, and the platform-days NOT COVERED; the access rows must equal the rows of
access_population.csv.
Run it on your own data
To run it on your own data, save your exports as CSV files, each with a header row, in one folder: your financial tables, your logins, and for each platform either its dated grants and members (Snowflake) or one export of grants and members for each day. Then change the settings at the top of the script and nothing else. Set DATA_DIR to that folder. Set PERIOD_START and PERIOD_END to the first and last day of the audit period, and AS_OF to the day you took the exports. Set PLATFORMS to the platforms you exported; a platform with no rows in its files is reported as NOT CHECKED. In FORM, write history or snapshots for each platform, and in HISTORY_DAYS give the number of days each history platform keeps. Set FILE_NAMES to the names of your files. In COLUMN_MAP, change the name on the right of each line to the column name in your file; leave the names on the left as they are, and names are matched without regard to case. Write every date as YYYY-MM-DD. Then run the script with no options.
We ran those sentences literally on a reader's data built for the purpose, with the period 2026-04-01 to 2026-06-30. It is a small invented set: other file names, other column names in another order, platform names typed in mixed case, a database and a role written in lower case in one file and upper case in another, a blank holder kind, a blank grantor, a grant on a whole Databricks catalog (blank schema and table), two service logins with no email, a role membership of nine days, one day with no Databricks export, and Redshift in PLATFORMS with no rows. Run 8 is its output. The nine days, the missing day, the two unmatched logins and the unexported platform all printed, and no input check failed.
Population: 15 rows for 2 financial tables, period 2026-04-01 to 2026-06-30 (91 days), exports taken 2026-07-08, SHA-256 18aa8531687250d4... Platforms snowflake checked history: 91 of 91 days covered databricks checked snapshots: 90 of 91 days covered redshift NOT CHECKED snapshots: 0 of 91 days covered 1. Who could read or change each financial table (principals with a person's email where logins.csv gives one) table principal kind access first day last day days how acme.finance.revenue analysts role or group read 2026-04-01 2026-06-30 90 direct (SELECT) acme.finance.revenue quinn@corp.example login read 2026-04-01 2026-06-30 90 via analysts (SELECT) acme.finance.revenue rae@corp.example login read 2026-04-01 2026-06-30 90 via analysts (SELECT) acme.finance.revenue sp-loader login change 2026-04-01 2026-06-30 90 direct (MODIFY) ACME_FIN.GL.TRIAL_BALANCE FIN_VIEWERS role or group read 2026-04-01 2026-06-30 91 direct (SELECT) ACME_FIN.GL.TRIAL_BALANCE LEDGER_OWNER role or group change 2026-04-01 2026-06-30 91 direct (OWNERSHIP) ACME_FIN.GL.TRIAL_BALANCE LEDGER_OWNER role or group read 2026-04-01 2026-06-30 91 direct (OWNERSHIP) ACME_FIN.GL.TRIAL_BALANCE LENA login change 2026-04-01 2026-06-30 91 via LEDGER_OWNER (OWNERSHIP) ACME_FIN.GL.TRIAL_BALANCE LENA login read 2026-04-01 2026-06-30 91 via LEDGER_OWNER (OWNERSHIP) ACME_FIN.GL.TRIAL_BALANCE MARA login read 2026-04-01 2026-06-30 91 via fin_viewers (SELECT) ACME_FIN.GL.TRIAL_BALANCE NOOR login read 2026-04-01 2026-06-30 91 via fin_viewers (SELECT) ACME_FIN.GL.TRIAL_BALANCE OMAR login change 2026-04-01 2026-06-30 91 via POSTERS (INSERT) ACME_FIN.GL.TRIAL_BALANCE PAT login change 2026-05-12 2026-05-20 9 via POSTERS (INSERT) ACME_FIN.GL.TRIAL_BALANCE POSTERS role or group change 2026-04-01 2026-06-30 91 direct (INSERT) ACME_FIN.GL.TRIAL_BALANCE SVC_LOAD login change 2026-04-01 2026-06-30 91 via POSTERS (INSERT) 7 people matched by email; 2 login(s) with no email in logins.csv; 4 role(s) or group(s) listed. unmatched login databricks sp-loader (1 financial table(s)) unmatched login snowflake SVC_LOAD (1 financial table(s)) 2. Changes in the period (grants, revokes and people added to or removed from a role or group that holds access) 2026-05-12 MEMBER ADDED snowflake ACME_FIN.GL.TRIAL_BALANCE POSTERS PAT by SECURITYADMIN (dated records) 2026-05-20 MEMBER REMOVED snowflake ACME_FIN.GL.TRIAL_BALANCE POSTERS PAT by SECURITYADMIN (dated records) 3. NOT COVERED (days these exports cannot rebuild; never read as nobody had access) databricks 2026-05-27 to 2026-05-27 (1 day(s)): no export for this day redshift 2026-04-01 to 2026-06-30 (91 day(s)): no snapshots exported 2 financial tables, 15 access rows, 2 changes, 92 platform-day(s) NOT COVERED. Evidence written to access-out/: access_population.csv (15 rows), access_changes.csv, access_not_covered.csv.
Download: financial_table_access.py · Get the full guide (PDF)
Get the full guide as a PDF
We'll email you a link to it. Please use your work email.
Thanks, . We're sending the link to now. If it isn't there in a few minutes, check your spam or junk folder, or email info@datameaning.com.
Method
- Records in Runs 1 to 4: generated by the script with no random numbers: ten people with
example.comaddresses, three platforms, six financial tables and one that is not, all invented. Nothing in them is a measurement of any company or platform. - Records in Runs 5 to 7: real exports from our own sandbox accounts after real grants and a revoke, with the export dates written by hand as stated under Runs 5 to 7.
- Column names: taken from the platforms' pages linked in Steps 2 and 3 (read 2026-11-06).
- Days: a grant or membership counts on every day from its first day to its last, both included. In the
historyform the days are the dates ofCREATED_ONandDELETED_ON; a record with no end is held to the day of the export. In thesnapshotsform a record counts from the first export it appears in to the last export in an unbroken run of exports, and the change is dated to the first export without it. - Covered days: a
historyplatform covers theHISTORY_DAYSdays ending onAS_OF. Asnapshotsplatform covers a day only if there is an export for it (any grant row that day), and, when members were exported at all, a member row that day. A day afterAS_OFis never covered. An empty export day therefore reads as a missing day, never as an empty one. A table is covered on a day only if its platform is, and also: in the snapshots form, the table's own export (oneSHOW GRANTSper Snowflake table, one export per Databricks catalog or Redshift database) has a row that day; in the history form, the day is on or after the earliest grant on the table, becauseGRANTS_TO_ROLESdoes not contain grants on dropped objects, so a replaced table's history starts when it was replaced. A table is also not covered on a day when a role or group holds a privilege on it but its members were not exported: in the snapshots form, a Snowflake role that holds a privilege on the table, an exported role above that role in the hierarchy, or a share (held directly or through a database role) with no member rows that day, not even the marker row the member export writes for each role it exports (SHOW GRANTS OF ROLE returns nothing for a role with no members), or a Databricks or Redshift platform with no member export at all while its roles or groups hold access to a financial table. The history form needs no such check: its views list every role membership with dates, and the report names any share, which has no members to list, as a holder whose people are not in the list. - Checks and what trips each: a start date not written YYYY-MM-DD (
08/12/26); a membership that ends before it starts (DEVinFIN_POSTER, 2026-09-01 to 2026-08-01); a snapshot dated afterAS_OF(2026-10-09); a platform not inPLATFORMS(oracle); a holder kind not known (bot); a blank key column (a blank holder); a grant naming a table but no schema; a financial table listed twice; a login listed twice (ADA, two emails); a financial table no grant record names (NO_SUCH_TABLE); an unknown privilege (FLY); a missing or renamed column (holderrenamedrolein Run 4). NOT COVERED: a Redshift day with no export (2026-08-14), days before the first export (the nine before 2026-07-10), and a platform with no rows (Redshift in Run 8). Not planted by any run: the settings checks (a realPERIOD_START,PERIOD_END,AS_OF; the end on or after the start;PLATFORMSandFORMfrom the known names; a number inHISTORY_DAYS; at least one financial table), and the two NOT COVERED reasons “after the day the exports were taken” and “before the platform's records start” (a period that begins more thanHISTORY_DAYSdays beforeAS_OF), and the per-table reasons: before the first export of a table or catalog, no export of a table or catalog for a day, before the earliest grant on a table, a role, a role above it in the hierarchy, or a share reaching the table with no member rows for that day, not even its marker row (Snowflake snapshots), and a snapshot platform whose roles or groups hold access to a financial table but which has no member export. - Mapping tables written out: the privilege lists, the holder kinds and
PUBLIC_HOLDERSare at the top of the script; edit them if you disagree. - Spreadsheet safety: a text cell in the output files that starts with
=,+,-,@, a tab or a carriage return gets a'in front, so a spreadsheet shows it as text and does not run it as a formula.
Limits
- This is not a client result and not a measurement. Runs 1 to 4 and Run 8 use generated or invented records. Runs 5 to 7 are real exports from sandbox accounts of ours with hand-written export dates. None of them says anything about how access changes in a real company.
- The history form is untested on a real account. Our Snowflake role cannot read
ACCOUNT_USAGE, so the history-form queries follow the documentation and have not been run. The Redshift queries have not been run either; we had no Redshift account. - Access is counted from grants, not from use. The script says who could reach a table, not who did. A privilege that needs another privilege to work (
USAGEon a schema,USE CATALOG) is counted as if it did not. Access through a view over a financial table is not followed unless you list the view. - One export a day can miss a short grant. A grant made and revoked between two exports is in neither. Only the history form (Snowflake) sees it. A change in the snapshot form is dated to the first export that shows it, and its maker is not recorded.
- Databricks and Redshift have no view of past group members. Databricks' audit log records each addition and removal, but the script does not read the audit log; it reads a member export a day. Without one, a group's people are not listed and the group is printed as having no members exported.
- The audit log is not read. A Databricks reader who can read
system.access.audithas a second source for grant changes, but the script does not use it, because the contents of thechangesfield are not in the documentation we read and we could not read an event. - Redshift keeps seven days of its change log. The system tables page says “STL system views retain seven days of log history” (read 2026-11-06), and STL_UTILITYTEXT, which holds
GRANTandREVOKEstatements, is such a view. So the script does not parse it. A quarter on Redshift needs an export a day, started before the quarter, and any day without one is NOT COVERED. - Who can grant themselves access has no row until they do. A holder of
MANAGEorMANAGE GRANTSshows when it grants itself something, not before. - Names are compared without regard to case. Snowflake can hold two objects whose names differ only by case when quoted; the script would treat them as one.
- Evidence, not a conclusion. What it prints is one input to a review. It does not say a review or a framework has been passed, and your own policy and your auditor decide what is enough.
Tested on
- Platform
- DuckDB 1.5.6 with Python 3.12, run from Python with the
duckdbmodule (the runs shown). Runs 1 to 4 and 7 printed the same lines on DuckDB 2.0.0 with Python 3.14, apart from the run time. - Edition
- Open-source DuckDB, in-memory. The Run 5 and Run 6 exports came from Snowflake Enterprise Edition and from Azure Databricks, Premium tier, serverless compute, Unity Catalog.
- Date
- 2026-11-06
- Dataset
- Runs 1 to 4: generated records, shaped like the three platforms' documented columns. Runs 5 to 7: real grant and owner exports from Snowflake and Databricks sandbox accounts, and real role-member exports from Snowflake, after real grants and a revoke, with export dates written by hand. The Snowflake part used
SHOW GRANTSbecause our role was refusedACCOUNT_USAGE, so the history form has not run on a real account. A separate check on the same Snowflake sandbox, shown in Step 3, exported one database role's grants and members. Run 8: an invented reader layout. There was no Redshift account: nothing here is a run on Redshift. - Code
- financial_table_access.py
When this is not enough
- You need it every night, not once. A single run is a rebuild from the exports you saved. Running it on a schedule, with the exports kept, is the next step, and daily exports are what make Databricks and Redshift covered.
- You need the approvals, not only the access. The script lists who could reach each table. Whether each grant followed a request someone approved is a second record, and the guide on SOX access evidence for Snowflake, Databricks and Redshift covers it.
- Your auditor wants the logins tied to people. The script matches by the email you give it. A login with no row is listed, not guessed.
- Finding is not fixing. The script changes nothing. Removing access, or writing down why it stayed, is a decision for the people who own the tables.
Governance Evidence Pack: SOX access evidence, in early access
Governance Evidence Pack, a module of Data Control Tower, is in early access and open now to design partners. It reads your platforms and never writes to them. It gives you evidence for every day since Data Control Tower was installed, not a single day's screenshot.
Data Control Tower lists every access change and who made it. We produce the evidence. Your auditor decides.