Check recipe · Access evidence

SOX access evidence in Snowflake, Databricks and Redshift: approvals, leavers and segregation of duties

DuckDB2026-10-31Script and PDF downloadable

The short answer

A SOX auditor decides what to test; we read the SEC's "access to programs and data" as three checks on financial tables: access approved before it was given, leavers removed on time, and no one both changing the data and giving or approving access to it. Our DuckDB script runs them from platform records plus your approval and HR files.

What the SEC and the statute say about access

Section 404(a) of the Sarbanes-Oxley Act directs the SEC to require each annual report to contain an internal control report that, under (a)(2), must contain an assessment, as of the end of the most recent fiscal year of the issuer of the effectiveness of the internal control structure and procedures of the issuer for financial reporting, and 404(b) says the outside auditor shall attest to, and report on, the assessment. Section 404(b) does not apply to an emerging growth company, and Section 404(c) removes the attestation for issuers that are neither large accelerated filers nor accelerated filers, so check which you are (15 U.S.C. 7262 (SOX Section 404) (read 2026-10-31), subsections (a)(2), (b) and (c)).

The SEC’s interpretive guidance for management names access in its own words: For purposes of the evaluation of ICFR, management only needs to evaluate those IT general controls that are necessary for the proper and consistent operation of other controls designed to adequately address financial reporting risks. For example, management might consider whether certain aspects of IT general control areas, such as program development, program changes, computer operations, and access to programs and data, apply to its facts and circumstances. (SEC Release 33-8810 (read 2026-10-31), section II.A.1.d, page 20). Footnote 37 adds: However, the reference to these specific IT general control areas as examples within this guidance does not imply that these areas, either partially or in their entirety, are applicable to all facts and circumstances.

We looked for PCAOB AS 2201 and could not read it (the PCAOB site answered us with a bot check), so this page does not say what it requires. What follows is our reading of what “access to programs and data” means for financial tables in a data platform: three questions an auditor can put to the records. Which of them your auditor asks, and what they accept as an answer, is the auditor’s call.

  1. Approvals. For each access granted to a financial table, is there an approval, and is it dated on or before the grant, by someone other than the person who got the access?
  2. Leavers. For each person HR says left, was access to the financial table removed, how many days after the last day, and did the account use the table after that?
  3. Segregation of duties. Did anyone both change a financial table and run or approve access to it?

What you will have at the end

A Python script, sox_access_evidence.py, that reads each platform’s own access records, exported as CSV with the platform’s published column names, plus your approval log, an HR leaver file and a login map. It runs eleven input checks first, then prints the three lists, each with its period, its filters, the run time, its row count and a SHA-256 of its rows, and writes each as a CSV. A platform you do not export is printed as NOT CHECKED, never as clean. The details are also in a PDF: Download the full guide (PDF).

The records we ran it on are generated: a seeded stand-in, not a company’s data and not a measurement of anything. We have not run the script on a real Snowflake, Databricks or Redshift account. This page produces evidence. It does not say a control is satisfied or that anyone will pass an audit.

What you will need

  • Access to the platforms’ own records: on Snowflake the ACCOUNT_USAGE views, on Databricks the system tables, on Redshift the system views.
  • An approval log, an HR leaver file and a login map, as CSV files.
  • Python 3 and the duckdb module.

Step 1. Export each platform’s records, once, into one folder

The script reads these files from EXPORT_DIR. Column names are the platforms’ own; the script lower-cases the header, so CREATED_ON and created_on both work, and it stops and says which column is missing if one is not there.

The twelve files
FileColumns the script readsDefined in
snowflake_grants_to_users.csvCREATED_ON, DELETED_ON, ROLE, GRANTEE_NAME, GRANTED_BYSnowflake: GRANTS_TO_USERS (read 2026-10-31)
snowflake_grants_to_roles.csvCREATED_ON, PRIVILEGE, GRANTED_ON, NAME, TABLE_CATALOG, TABLE_SCHEMA, GRANTED_TO, GRANTEE_NAME, GRANTED_BY, DELETED_ONSnowflake: GRANTS_TO_ROLES (read 2026-10-31)
snowflake_query_history.csvQUERY_ID, QUERY_TEXT, QUERY_TYPE, USER_NAME, ROLE_NAME, START_TIME, EXECUTION_STATUSSnowflake: QUERY_HISTORY (read 2026-10-31)
snowflake_access_history.csvQUERY_ID, QUERY_START_TIME, USER_NAME, OBJECTS_MODIFIEDSnowflake: ACCESS_HISTORY (read 2026-10-31)
databricks_audit.csvevent_time, event_date, user_identity, service_name, action_name, request_params, response (user_identity, request_params and response as JSON text)Databricks: audit log system table (read 2026-10-31)
databricks_table_lineage.csvtarget_table_full_name, created_by, event_time, entity_typeDatabricks: lineage system tables (read 2026-10-31)
redshift_stl_utilitytext.csvuserid, xid, pid, starttime, endtime, sequence, textAmazon Redshift: STL_UTILITYTEXT (read 2026-10-31)
redshift_sys_userlog.csvuser_id, user_name, action, record_timeAmazon Redshift: SYS_USERLOG (read 2026-10-31)
redshift_sys_query_history.csvuser_id, username, query_id, query_type, status, start_time, query_textAmazon Redshift: SYS_QUERY_HISTORY (read 2026-10-31)
approvals.csvyour approval log: one row per approved access, with who it was approved for (email), the table, the approver (email) and the day of approvalyours (the column names are settings)
hr_leavers.csvyour HR file of leavers: work email and last day of work, written YYYY-MM-DDyours (the column names are settings)
login_map.csvplatform, login, email: one row for every login that is not itself an email addressyours

What each platform keeps, and for how long, decides what you can show. Snowflake’s QUERY_HISTORY and ACCESS_HISTORY views each cover the last 365 days, with a latency of up to 45 minutes for QUERY_HISTORY and up to 180 minutes for ACCESS_HISTORY (Snowflake: QUERY_HISTORY (read 2026-10-31); Snowflake: ACCESS_HISTORY (read 2026-10-31)), and GRANTS_TO_USERS can lag by up to 120 minutes (Snowflake: GRANTS_TO_USERS (read 2026-10-31)), so take the export some hours after the last event you need. Databricks’ lineage tables keep a rolling window of one year (Databricks: lineage system tables (read 2026-10-31)). On Redshift, STL system views retain seven days of log history and keeping more means copying them to other tables or unloading them to Amazon S3 (Amazon Redshift: System tables and views reference (read 2026-10-31)). STL_UTILITYTEXT, where the script reads grants and revokes, is an STL view, so a quarter of Redshift grants needs a daily export or an unload to S3. We did not find a retention figure for the SYS views the script also reads, so check yours. The script checks every day from PERIOD_START to AS_OF for records in each platform’s query or audit history and fails if a day has none. It does not check that the grants files cover the whole period, so confirm that STL_UTILITYTEXT was exported daily.

Step 2. Name the financial tables, and read every timestamp strictly

FINANCIAL_TABLES has no default. Unset, the script stops and says so, because a check with no scope would pass by looking at nothing. Each platform gets its own list of lower-case names (database.schema.table on Snowflake and Databricks, schema.table on Redshift). A name that matches no grant, use or removal in your exports is an input check failure, so a typo cannot send a table to nowhere.

Timestamps are read by one pattern: YYYY-MM-DD HH:MM:SS, with optional fractional seconds and an optional Z, +00:00 or +0000 suffix. A timestamp with no suffix is taken as UTC, so set your session time zone to UTC when you export; any other offset is counted as unreadable. A lenient date cast can misread a value like 8/5/26 and drop that row from the period without a word, so anything else is counted, listed and fails the input check.

def ts(col):
    """A timestamp column read strictly: YYYY-MM-DD HH:MM:SS, taken as UTC (a Z, +00:00 or +0000 suffix is
    accepted, any other offset is NULL and counted). Export from a session set to UTC."""
    return (f"CASE WHEN regexp_full_match(trim({col}), '{TS_RE}') THEN "
            f"try_strptime(substr(replace(trim({col}), 'T', ' '), 1, 19), '%Y-%m-%d %H:%M:%S') END")


def day(col):
    return (f"CASE WHEN regexp_full_match(trim({col}), '{DATE_RE}') THEN "
            f"try_strptime(trim({col}), '%Y-%m-%d')::DATE END")

Step 3. Run eleven input checks before building anything

A list built from incomplete records looks complete, which makes it a risky kind of evidence. The script runs these checks first, and each one names the kind of fault it catches; seven of the eleven trip in run 1. They are: duplicate Snowflake query ids; timestamps that are not YYYY-MM-DD HH:MM:SS in UTC; days with no records on a platform; scope names that match nothing; Databricks audit events (a permission change, or a response status) or write lists it could not read; Redshift GRANT or REVOKE statements on a financial table it could not parse; HR rows with no email or no last day; an HR last day not written YYYY-MM-DD; approval rows with a blank grantee, table, approver or date; approval dates not written YYYY-MM-DD; login map rows with a blank login or email. If any fails, all three lists are printed with INCOMPLETE in their heading.

Step 4. Turn each platform’s records into the same three events

The platforms record access differently, so the script first reduces each to grants, removals and use of the financial tables, at login level, then matches logins to people.

Snowflake. A person gets access by being granted a role. The script joins each GRANTS_TO_USERS row to the privileges that role holds on a financial table in GRANTS_TO_ROLES at that moment. GRANTS_TO_USERS carries DELETED_ON, which Snowflake documents as the time the role was revoked from the user, and which stays empty while the grant is active (Snowflake: GRANTS_TO_USERS (read 2026-10-31)). The person who ran the grant is not in that view: the script assumes GRANTED_BY is the role the grant ran under and looks in QUERY_HISTORY for the GRANT statement run under that role within 60 seconds that names the role and the grantee. If it finds none, the row shows a dash for the person and counts as follow-up. Writes come from ACCESS_HISTORY, whose OBJECTS_MODIFIED is a JSON array of the objects associated with a write (Snowflake: ACCESS_HISTORY (read 2026-10-31)).

        con.execute(f"""CREATE TABLE sf_acc AS
            SELECT u.ts, u.end_ts, u.login, u.role, u.granted_by, r.obj, string_agg(DISTINCT lower(replace(r.privilege, '_', ' ')), ',' ORDER BY lower(replace(r.privilege, '_', ' '))) AS privs
            FROM sf_gu u JOIN sf_gr r ON r.role = u.role AND r.obj IN (SELECT obj FROM scope WHERE platform = 'snowflake')
                 AND lower(replace(r.privilege, '_', ' ')) IN (SELECT p FROM privs) AND r.ts <= u.ts AND (r.end_ts IS NULL OR r.end_ts > u.ts)
            WHERE u.ts IS NOT NULL GROUP BY ALL""")
        con.execute("""CREATE TABLE sf_g AS WITH m AS (
              SELECT a.*, q.login AS grantor, row_number() OVER (PARTITION BY a.ts, a.login, a.role, a.obj
                       ORDER BY abs(epoch(q.ts) - epoch(a.ts))) AS rn
              FROM sf_acc a LEFT JOIN sf_qh q ON q.query_type = 'GRANT' AND q.role = a.granted_by
                   AND abs(epoch(q.ts) - epoch(a.ts)) <= 60 AND contains(q.text, a.role) AND contains(q.text, a.login))
              SELECT * FROM m WHERE rn = 1""")

Databricks. The audit table records the user, service, action, request parameters and response of each event (Databricks: audit log system table (read 2026-10-31)). The script reads updatePermissions events under unityCatalog and takes the table from securable_full_name and the principals and privileges added or removed from changes. Databricks’ audit log reference lists securable_full_name and changes as the request parameters of updatePermissions (Databricks: audit log reference (read 2026-10-31)), and the system-table page shows full_name_arg and a statusCode in its examples. It does not document the layout inside changes, so how the script reads the principals and privileges added or removed is our assumption: open one of your own events and check it. It counts only events whose response status is 200: a permission change with another status is left out and counted on an [info] line, a read with another status is a refused attempt and not use, and an event on a financial table whose status cannot be read fails an input check (events on other tables are not looked at). If your export carries no status on some of those events, that check will fail on clean data: open one of your own events and check. Writes come from the lineage table’s target_table_full_name and created_by (Databricks: lineage system tables (read 2026-10-31)).

Redshift. Grants and revokes are in STL_UTILITYTEXT, where a statement longer than 200 characters is stored in pieces ordered by sequence (Amazon Redshift: STL_UTILITYTEXT (read 2026-10-31)). The script puts them back in order, strips comments, and parses GRANT and REVOKE with one privilege list, one table and one grantee. A GRANT or REVOKE that names a financial table in full but does not fit the one-table, one-grantee pattern fails an input check. The user id becomes a name through SYS_QUERY_HISTORY’s username (Amazon Redshift: SYS_QUERY_HISTORY (read 2026-10-31)); a dropped user is a removal in SYS_USERLOG (Amazon Redshift: SYS_USERLOG (read 2026-10-31)).

        # a statement longer than 200 characters is stored in pieces: put them back in order, as AWS shows
        con.execute(f"""CREATE TABLE rs_dcl AS
            WITH st AS (SELECT uid, xid, pid, st_text, min(ts) AS ts,
                          string_agg(CASE WHEN len(rtrim(text)) = 0 THEN text ELSE rtrim(text) END, '' ORDER BY seq) AS stmt
                        FROM rs_ut GROUP BY uid, xid, pid, st_text),
                 cl AS (SELECT *, trim(regexp_replace(stmt, '/\\*.*?\\*/', '', 'gs')) AS s FROM st)
            SELECT uid, ts, s, lower(regexp_extract(s, '{DCL_RE}', 1)) AS kind, regexp_extract(s, '{DCL_RE}', 2) AS privs,
                   lower(replace(regexp_extract(s, '{DCL_RE}', 3), '"', '')) AS obj,
                   lower(replace(regexp_extract(s, '{DCL_RE}', 4), '"', '')) AS grantee
            FROM cl WHERE regexp_matches(s, '(?i)^\\s*(grant|revoke)\\b')""")

People. A login that is itself an email address is the person; any other login needs a row in login_map.csv. Emails are lower-cased and trimmed, so Ana.Ruiz@Corp.Example in the approval log and ANA.RUIZ@CORP.EXAMPLE in Snowflake are the same person. A login that wrote to a financial table or ran a grant and matches no person is listed, never dropped.

Step 5. List 1: was each access approved, and before it was given?

Every grant the script can see on the tables you name, in the period, is matched to an approval for the same person and table. If more than one approval exists, the closest one dated on or before the grant wins, and only if there is none is the closest later one used. The status is then NO APPROVAL, SELF-APPROVED (the approver is the person who got the access), APPROVED AFTER GRANT, or approved before grant. The script checks the approval’s date, not who the approver should have been: whether that person was entitled to approve is for you and your auditor.

        WITH g AS (SELECT row_number() OVER (ORDER BY ts, platform, login, obj, privs) AS gid, * FROM grants_r
                   WHERE ts::DATE BETWEEN DATE '{p0}' AND DATE '{p1}'),
             cand AS (SELECT g.gid, a.approver, a.approved_on,
                        row_number() OVER (PARTITION BY g.gid ORDER BY CASE WHEN a.approved_on <= g.ts::DATE THEN 0 ELSE 1 END,
                                           abs(date_diff('day', a.approved_on, g.ts::DATE)), a.approver = coalesce(g.email, g.login), a.approver) AS rn
                      FROM g JOIN approvals a ON a.grantee = coalesce(g.email, g.login) AND a.obj = g.obj AND a.approved_on IS NOT NULL
                           AND a.approver <> '')
        SELECT g.platform, g.ts::DATE AS granted_on, coalesce(g.email, g.login) AS grantee, g.obj AS object, g.privs,
               coalesce(g.grantor_email, g.grantor_login, '-') AS granted_by_person, c.approver, c.approved_on,
               CASE WHEN c.gid IS NULL THEN 'NO APPROVAL'
                    WHEN c.approver = coalesce(g.email, g.login) THEN 'SELF-APPROVED'
                    WHEN c.approved_on > g.ts::DATE THEN 'APPROVED AFTER GRANT'
                    ELSE 'approved before grant' END AS status
        FROM g LEFT JOIN cand c ON c.gid = g.gid AND c.rn = 1 ORDER BY g.ts, g.platform, grantee, g.obj, g.privs"""

Step 6. List 2: leavers, each departure on its own

The list starts from the HR file, not from the removal records, so a person who was never removed still appears. A leaver is listed on a platform if your exports show a grant to them there, or if their account used a financial table there after the grace period; anyone else appears only in the [info] count, so read that line. For each leaver and each platform where they held access to a financial table, the script finds the first removal record credited to that departure, counts the days since the last day of work, counts use of a financial table after a grace period of one day (GRACE_DAYS) and before the removal, and counts use after the removal. Use after the removal makes the row ACTIVE AFTER REMOVAL. Each removal is credited to one departure only: the latest HR last day on or before it, or, for a removal before every last day, the earliest last day it precedes by at most 30 days. So someone who left, was removed, came back and left again is two rows, and the first removal never counts for the second departure. For a person who later leaves again, use after the first removal is not counted against the first row, because a rehired person legitimately uses the table in the second stint. A leaver with no grant record on a platform is listed there only if their account used a financial table after the grace period, which is what a grant older than the exports looks like; otherwise the script counts them on an [info] line.

        WITH lv AS (SELECT email, last_day, lag(last_day) OVER (PARTITION BY email ORDER BY last_day) AS prev_last_day,
                           lead(last_day) OVER (PARTITION BY email ORDER BY last_day) AS next_last_day
                    FROM leavers WHERE email <> '' AND last_day IS NOT NULL),
             pairs AS (SELECT l.*, p.platform FROM lv l CROSS JOIN (VALUES {plat_vals}) p(platform)
                       WHERE l.last_day BETWEEN DATE '{p0}' AND DATE '{p1}' AND (EXISTS (
                         SELECT 1 FROM grants_r g WHERE g.email = l.email AND g.platform = p.platform AND g.ts::DATE <= l.last_day
                           AND (l.prev_last_day IS NULL OR g.ts::DATE > l.prev_last_day)) OR EXISTS (
                         SELECT 1 FROM usage_r u WHERE u.email = l.email AND u.platform = p.platform
                           AND u.ts::DATE > l.last_day + INTERVAL {GRACE_DAYS} DAY
                           AND l.next_last_day IS NULL))),   -- a rehire's earlier departure needs a grant record
             credited AS (SELECT e.email, e.platform, e.ts, coalesce(
                            (SELECT max(l.last_day) FROM lv l WHERE l.email = e.email AND l.last_day <= e.ts::DATE),
                            (SELECT min(l.last_day) FROM lv l WHERE l.email = e.email
                              AND l.last_day - INTERVAL {REMOVAL_WINDOW_DAYS} DAY <= e.ts::DATE)) AS last_day
                          FROM ends_r e WHERE e.email IS NOT NULL),
             rem AS (SELECT email, platform, last_day, min(ts) AS removed_at FROM credited WHERE last_day IS NOT NULL GROUP BY ALL),

Step 7. List 3: who both changed the ledger and gave access to it?

The script lists anyone who wrote to a financial table in the period and also ran a grant of access to one, or approved access to one, matching people by email across all platforms. Then it lists every login that matched no person.

        WITH w AS (SELECT email, count(*) AS writes, string_agg(DISTINCT platform, ',' ORDER BY platform) AS wrote_on FROM usage_r
                   WHERE is_write AND email IS NOT NULL AND ts::DATE BETWEEN DATE '{p0}' AND DATE '{p1}' GROUP BY 1),
             g AS (SELECT grantor_email AS email, count(*) AS grants_run FROM grants_r WHERE grantor_email IS NOT NULL
                   AND ts::DATE BETWEEN DATE '{p0}' AND DATE '{p1}' GROUP BY 1),
             a AS (SELECT approver AS email, count(*) AS approvals_given FROM approvals WHERE approver <> ''
                   AND obj IN (SELECT obj FROM scope) AND approved_on BETWEEN DATE '{p0}' AND DATE '{p1}' GROUP BY 1)
        SELECT w.email, w.writes, w.wrote_on, coalesce(g.grants_run, 0) AS grants_run, coalesce(a.approvals_given, 0) AS approvals_given,
               'WROTE AND ' || CASE WHEN g.grants_run > 0 AND a.approvals_given > 0 THEN 'GRANTED AND APPROVED'
                                    WHEN g.grants_run > 0 THEN 'GRANTED' ELSE 'APPROVED' END AS status
        FROM w LEFT JOIN g ON g.email = w.email LEFT JOIN a ON a.email = w.email
        WHERE coalesce(g.grants_run, 0) + coalesce(a.approvals_given, 0) > 0 ORDER BY w.email"""

Each list is fingerprinted with a SHA-256 of its rows, in the order written to its CSV. The fingerprint does not show the rows are right. It lets anyone who re-runs the script on the same files confirm they got the same rows, and shows if a list changed after it was handed over.

Run 1: faults planted, and the script finds them

We ran the script on 2026-10-31 on generated records with these faults planted: a Snowflake grant approved three days after it was given; a Redshift grant approved by the person who received it; a Databricks grant with no approval; a Snowflake leaver never removed who kept reading the table; a Redshift leaver removed nine days after the last day; a Snowflake leaver removed on time whose account read the table nine days later; a rehired Databricks leaver who left a second time and was never removed; an administrator who ran grants and also wrote to the ledger table; a Redshift login nobody mapped; and seven input faults (a repeated query id, an unreadable timestamp, a day with no Redshift records, a typo in the scope, a grant to two users in one statement, a last day written 8/5/26 and a blank approver). The script reported all of them and marked all three lists INCOMPLETE. It prints RESULT: EXCEPTIONS FOUND and exits with code 0 unless you pass --strict, which exits 1 so a pipeline can stop.

Command: run 1
python3 sox_access_evidence.py --demo --plant
Output from our run 1
input     : GENERATED records (not a company's data), seed 228, plant=True
platforms : checked snowflake, databricks, redshift

== Input checks   (span checked for records: 2026-07-01 to 2026-10-15)
[FAIL] duplicate Snowflake query ids: 1   (trips on: a repeated query id)
[FAIL] timestamps not written YYYY-MM-DD HH:MM:SS (UTC): 1   (trips on: a timestamp written 07/09/2026 10:00)
[FAIL] days with no records, per platform, from PERIOD_START to AS_OF: 1   (trips on: a day with no Redshift records)
[FAIL] scope names that match no grant, use or removal in the exports: 1   (trips on: a typo in FINANCIAL_TABLES)
[ok  ] Databricks audit events or Snowflake write lists the script could not read: 0
[FAIL] Redshift GRANT or REVOKE on a financial table the script could not parse: 1   (trips on: a grant to two users in one statement)
[ok  ] HR leaver rows with no email or no last day: 0
[FAIL] HR leaver rows whose last day is not written as YYYY-MM-DD: 1   (trips on: a last day written 8/5/26)
[FAIL] approval rows with a blank grantee, table, approver or date: 1   (trips on: a blank approver)
[ok  ] approval rows whose date is not written as YYYY-MM-DD: 0
[ok  ] login map rows with a blank login or email: 0
        unreadable: GRANTS_TO_USERS.CREATED_ON: 1
        redshift days with no records: 2026-08-19
        the STL views keep seven days (AWS): export daily, or unload the logs to Amazon S3
        scope: redshift:ledger.gl_entires
[info] permission changes that failed (status not 200), left out: 1
[info] grants in the period where no person who ran them was found: 0

== Access approved before it was granted: sox_1_access_grants   ** INCOMPLETE: see input checks **
period    : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters   : grants of ['all', 'all privileges', 'delete', 'insert', 'modify', 'ownership', 'select', 'update'] on the tables in FINANCIAL_TABLES, granted in the period; one row per person and table
run at    : 2026-10-31 23:02:55 UTC
rows      : 6
sha256    : 73f5abcf0511706707b275816af19fb10edeb7a3ad3d858e7bae45757d6175ee
  snowflake | 2026-07-06 | f01@example.test | finance_db.ledger.gl_entries | select | a01@example.test | m01@example.test | 2026-07-09 | APPROVED AFTER GRANT
  databricks | 2026-07-08 | f01@example.test | finance.ledger.gl_entries | modify | a02@example.test | m01@example.test | 2026-07-07 | approved before grant
  redshift | 2026-07-15 | f02@example.test | ledger.gl_entries | insert | a01@example.test | f02@example.test | 2026-07-14 | SELF-APPROVED
  snowflake | 2026-07-22 | f03@example.test | finance_db.ledger.gl_entries | insert,select,update | a02@example.test | m02@example.test | 2026-07-20 | approved before grant
  databricks | 2026-07-28 | f08@example.test | finance.ledger.gl_entries | select | a02@example.test | m02@example.test | 2026-07-27 | approved before grant
  databricks | 2026-08-03 | f04@example.test | finance.ledger.gl_entries | select | a01@example.test | - | - | NO APPROVAL

== Leavers: sox_2_leavers   ** INCOMPLETE: see input checks **
period    : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters   : HR leavers with last day in the period who held access to a financial table on the platform, or used one after the grace period; removal records credited to one departure; grace 1 day(s)
run at    : 2026-10-31 23:02:55 UTC
rows      : 6
sha256    : c8078ffc669059afbc547aa8b7807cd7971febd68c0d2e3665b67e16d0e02d86
  f08@example.test | 2026-07-10 | databricks | 2026-07-11 | 1 | 0 | 0 | removed in time
  f05@example.test | 2026-08-07 | databricks | 2026-08-08 | 1 | 0 | 0 | removed in time
  f05@example.test | 2026-08-07 | snowflake | - | - | 2 | 0 | NO REMOVAL RECORD
  f06@example.test | 2026-08-21 | redshift | 2026-08-30 | 9 | 1 | 0 | REMOVED LATE
  f07@example.test | 2026-09-04 | snowflake | 2026-09-05 | 1 | 0 | 1 | ACTIVE AFTER REMOVAL
  f08@example.test | 2026-09-18 | databricks | - | - | 0 | 0 | NO REMOVAL RECORD
[info] HR leavers in the period with no grant on a financial table and no use after the grace period seen on snowflake, so not listed there: 3
[info] HR leavers in the period with no grant on a financial table and no use after the grace period seen on databricks, so not listed there: 2
[info] HR leavers in the period with no grant on a financial table and no use after the grace period seen on redshift, so not listed there: 4

== Segregation of duties: sox_3_segregation_of_duties   ** INCOMPLETE: see input checks **
period    : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters   : people (matched by email across platforms) who wrote to a financial table and also ran a grant of access to one or approved access to one, in the period
run at    : 2026-10-31 23:02:55 UTC
rows      : 2
sha256    : c4fe6b8a046697a53d984f6d9caa3e24eaebd360f4f2d123409ab52347d1fe43
  a02@example.test | 1 | databricks | 3 | 0 | WROTE AND GRANTED
  f02@example.test | 16 | redshift | 0 | 1 | WROTE AND APPROVED
[info] logins that wrote to a financial table or ran a grant and match no person (add them to login_map): 1
  UNMATCHED LOGIN | redshift | rs_batch2 | 1 | 0

== Result
input checks : FAILED, the lists above are not complete
not checked  : none
follow-up    : 3 grant row(s), 4 leaver row(s), 3 segregation or login row(s) need an explanation
RESULT: EXCEPTIONS FOUND

The result

List 1 in run 1: grants in the period (generated records)
PlatformGranted onGranteeGranted byApproverApproved onStatus
snowflake2026-07-06f01a01m012026-07-09APPROVED AFTER GRANT
databricks2026-07-08f01a02m012026-07-07approved before grant
redshift2026-07-15f02a01f022026-07-14SELF-APPROVED
snowflake2026-07-22f03a02m022026-07-20approved before grant
databricks2026-07-28f08a02m022026-07-27approved before grant
databricks2026-08-03f04a01nonenoneNO APPROVAL
List 2 in run 1: leavers (generated records)
PersonLast dayPlatformFirst removal recordDaysUses after graceUses after removalStatus
f082026-07-10databricks2026-07-11100removed in time
f052026-08-07databricks2026-08-08100removed in time
f052026-08-07snowflakenonenone20NO REMOVAL RECORD
f062026-08-21redshift2026-08-30910REMOVED LATE
f072026-09-04snowflake2026-09-05101ACTIVE AFTER REMOVAL
f082026-09-18databricksnonenone00NO REMOVAL RECORD
List 3 in run 1: segregation of duties (generated records)
PersonWritesWrote onGrants runApprovals givenStatus
a021databricks30WROTE AND GRANTED
f0216redshift01WROTE AND APPROVED

The first table is the approval test. The f01 grant on Snowflake was given on 2026-07-06 and approved on 2026-07-09, so the access came first. The f02 grant on Redshift was approved by f02. The f04 grant on Databricks has no approval row at all. The second table is the leaver test: f05 still reads the Snowflake table two times after the grace period and has no removal record; f06 was removed nine days after the last day; f07 was removed on time and then read the table again; and f08, who was rehired, has two rows, with the second never removed. In the third table, a02 ran three grants and wrote once to the ledger table, and f02, who wrote sixteen times on Redshift, also appears as an approver because of the self-approved grant. One login, rs_batch2, matched no person.

Run 2: the clean records, and the script passes

The same script on the same seed with --plant left off. All eleven input checks pass, all six grants were approved before they were given, all six leaver rows read removed in time (the rehired person included), no one both wrote and granted, and no login is unmatched. The script prints RESULT: NOTHING TO FOLLOW UP.

Command: run 2
python3 sox_access_evidence.py --demo
Output from our run 2
input     : GENERATED records (not a company's data), seed 228, plant=False
platforms : checked snowflake, databricks, redshift

== Input checks   (span checked for records: 2026-07-01 to 2026-10-15)
[ok  ] duplicate Snowflake query ids: 0
[ok  ] timestamps not written YYYY-MM-DD HH:MM:SS (UTC): 0
[ok  ] days with no records, per platform, from PERIOD_START to AS_OF: 0
[ok  ] scope names that match no grant, use or removal in the exports: 0
[ok  ] Databricks audit events or Snowflake write lists the script could not read: 0
[ok  ] Redshift GRANT or REVOKE on a financial table the script could not parse: 0
[ok  ] HR leaver rows with no email or no last day: 0
[ok  ] HR leaver rows whose last day is not written as YYYY-MM-DD: 0
[ok  ] approval rows with a blank grantee, table, approver or date: 0
[ok  ] approval rows whose date is not written as YYYY-MM-DD: 0
[ok  ] login map rows with a blank login or email: 0
[info] permission changes that failed (status not 200), left out: 1
[info] grants in the period where no person who ran them was found: 0

== Access approved before it was granted: sox_1_access_grants
period    : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters   : grants of ['all', 'all privileges', 'delete', 'insert', 'modify', 'ownership', 'select', 'update'] on the tables in FINANCIAL_TABLES, granted in the period; one row per person and table
run at    : 2026-10-31 23:02:55 UTC
rows      : 6
sha256    : c391d1c2901596ca863849f2ba7f7681bf10389a209f75b4733e9662c6dce484
  snowflake | 2026-07-06 | f01@example.test | finance_db.ledger.gl_entries | select | a01@example.test | m01@example.test | 2026-07-03 | approved before grant
  databricks | 2026-07-08 | f01@example.test | finance.ledger.gl_entries | modify | a02@example.test | m01@example.test | 2026-07-07 | approved before grant
  redshift | 2026-07-15 | f02@example.test | ledger.gl_entries | insert | a01@example.test | m02@example.test | 2026-07-14 | approved before grant
  snowflake | 2026-07-22 | f03@example.test | finance_db.ledger.gl_entries | insert,select,update | a02@example.test | m02@example.test | 2026-07-20 | approved before grant
  databricks | 2026-07-28 | f08@example.test | finance.ledger.gl_entries | select | a02@example.test | m02@example.test | 2026-07-27 | approved before grant
  databricks | 2026-08-03 | f04@example.test | finance.ledger.gl_entries | select | a01@example.test | m01@example.test | 2026-08-01 | approved before grant

== Leavers: sox_2_leavers
period    : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters   : HR leavers with last day in the period who held access to a financial table on the platform, or used one after the grace period; removal records credited to one departure; grace 1 day(s)
run at    : 2026-10-31 23:02:55 UTC
rows      : 6
sha256    : 0ce33859d8fc8b2872d8cbe4f8b341a7d142d6e365ae7d9a10a4757a3fac2345
  f08@example.test | 2026-07-10 | databricks | 2026-07-11 | 1 | 0 | 0 | removed in time
  f05@example.test | 2026-08-07 | databricks | 2026-08-08 | 1 | 0 | 0 | removed in time
  f05@example.test | 2026-08-07 | snowflake | 2026-08-08 | 1 | 0 | 0 | removed in time
  f06@example.test | 2026-08-21 | redshift | 2026-08-22 | 1 | 0 | 0 | removed in time
  f07@example.test | 2026-09-04 | snowflake | 2026-09-05 | 1 | 0 | 0 | removed in time
  f08@example.test | 2026-09-18 | databricks | 2026-09-19 | 1 | 0 | 0 | removed in time
[info] HR leavers in the period with no grant on a financial table and no use after the grace period seen on snowflake, so not listed there: 3
[info] HR leavers in the period with no grant on a financial table and no use after the grace period seen on databricks, so not listed there: 2
[info] HR leavers in the period with no grant on a financial table and no use after the grace period seen on redshift, so not listed there: 4

== Segregation of duties: sox_3_segregation_of_duties
period    : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters   : people (matched by email across platforms) who wrote to a financial table and also ran a grant of access to one or approved access to one, in the period
run at    : 2026-10-31 23:02:55 UTC
rows      : 0
sha256    : e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
[info] logins that wrote to a financial table or ran a grant and match no person (add them to login_map): 0

== Result
input checks : all passed
not checked  : none
follow-up    : 0 grant row(s), 0 leaver row(s), 0 segregation or login row(s) need an explanation
RESULT: NOTHING TO FOLLOW UP

“Nothing to follow up” says only that the input passed its checks and that no row in the three lists needed an explanation. It does not say a control is satisfied, and it covers only the platforms in PLATFORMS_EXPORTED: a platform left out is printed as NOT CHECKED, and the result line then says so instead of reading as clean.

Run it on your own data

To run this on your own data, export each platform's records as CSV into one folder, using the file names in the table above and each platform's own column names (upper or lower case does not matter), and put an approval log, an HR leaver file and a login map in the same folder. At the top of the script, set EXPORT_DIR to that folder, PERIOD_START and PERIOD_END to your audit period, AS_OF to the day you took the exports, PLATFORMS_EXPORTED to the platforms you exported, and FINANCIAL_TABLES to your financial tables for each platform, written in lower case as database.schema.table (Redshift: schema.table). Set KNOWN_GAPS to any days with no records that you can explain. If your HR file or approval log names its columns differently, set HR_EMAIL_COL, HR_LAST_DAY_COL, APPROVAL_GRANTEE_COL, APPROVAL_OBJECT_COL, APPROVAL_APPROVER_COL and APPROVAL_DATE_COL to your names. Add a row to the login map for every login, on any platform, that is not an email address. Change nothing else, and run it without --demo.

We tested those steps on a second, separate set of generated files: a different folder, different table names written in upper case on Snowflake, different column names in the HR file and the approval log, mixed-case emails, a blank email in the HR file and a blank approver in the approval log, Redshift logins mapped through the login map, a day with no Redshift records listed in KNOWN_GAPS, and Databricks left out of PLATFORMS_EXPORTED. The script caught the planted approval after the grant, the self-approval, the missing approval, the leaver never removed and the leaver removed late, found the administrator who both granted and wrote, accepted the explained gap, reported both blanks, and printed Databricks as NOT CHECKED. That run also showed us a bug, a blank HR email that slipped past the input check, which we fixed before publishing.

Download sox_access_evidence.py · Download the full guide (PDF)

Method

  • Input: generated records written by the script as CSV in each platform’s published column names (the links in step 1), with a fixed seed (228). The approval log, the HR file and the login map are generated too.
  • Period: 2026-07-01 to 2026-09-30, UTC, inclusive, for grants, approvals and writes. Completeness is checked from the period’s start to AS_OF (2026-10-15), because a removal and later use can fall after the period ends.
  • Use of a financial table is a successful query whose text, with double quotes removed, contains the table’s full name (Snowflake EXECUTION_STATUS success, Redshift not failed or canceled), an audit event with status 200 naming it in full_name_arg (Databricks), or a write recorded by ACCESS_HISTORY or the lineage table. A query that copies a financial table into another table counts as use of the financial table. A write on Redshift is an INSERT, UPDATE, DELETE or COPY whose target is the financial table; one that only reads it to fill another table is use, not a write.
  • Privileges that count as access are written at the top of the script (ACCESS_PRIVILEGES), so you can disagree with them. Underscores in privilege names are read as spaces, so Databricks’ ALL_PRIVILEGES is all privileges. The grace period and the removal window are set there too.
  • Unreadable input is counted, not dropped: a failed input check marks every list INCOMPLETE; a leaver with no grant record is counted, and listed if they used a financial table after the grace period; an unmatched login that wrote or ran a grant is listed. The Limits below list what the script cannot see.

Limits

  • This is not a client result. The records are generated. The runs show that the script finds planted faults. They say nothing about any real company or how often such faults occur.
  • We have not run it on a real account. Real exports can differ from the documentation, and the layout of the changes field in a Databricks permission change is our assumption.
  • These three tests are our reading, not a list from the SEC, the PCAOB or an auditor. We did not read PCAOB AS 2201. Your auditor decides what to test and what evidence to accept, and a different scope can need different lists.
  • A role that gains a privilege after a person received it is not seen as a new grant on Snowflake, because the script looks at what the role held when the user was granted it.
  • Use is matched on the table’s full name in query text, with double quotes removed. A query that names the table by a shorter name after a USE statement is not matched.
  • An approval is matched to a person and a table, not to a privilege. An approval for read access also covers a later grant of more privileges on the same table; check the privileges column of List 1 against what was approved.
  • Access that reaches a financial table by another route is not seen: a role granted to another role on Snowflake; a grant on a schema or catalog on Databricks; ALL TABLES IN SCHEMA, a schema grant or group membership on Redshift. The script reads a role granted directly to a user, table-level updatePermissions events, and GRANT ... ON a named table.
  • Timestamps with no zone suffix are taken as UTC. Snowflake exports follow the session time zone, so set it to UTC before you export.
  • Group and role principals on Databricks and Redshift are treated as logins: they appear in list 1 under the group’s name and match no approval unless your log uses the same name.
  • After a first removal, use is not counted for someone who later leaves again. That use is taken to be the second stint, so a removal that did not take before the rehire is not flagged.
  • The approver’s authority is not checked, only that the approval exists, is dated, and is not the grantee. A grant approved by a person who had no right to approve passes this test.
  • A removal record is not proof of logout. The script counts later use of the financial table as a second signal; a person can also read the data through a shared account or an extract.
  • Coverage: only what each platform records, for as long as it keeps it. Redshift keeps seven days in the STL views unless you export them.
  • The empty-day check can fire on a quiet day, and cannot see a day that has some records but not all.

Tested on

Tested on

Platform
DuckDB 1.5.6, Python 3.12, run from Python with the duckdb module
Edition
Open-source DuckDB, in-memory. Not run on Snowflake, Databricks or Redshift.
Date
2026-10-31
Dataset
Generated records, seed 228 (not a company’s data), in the column names of the platforms’ own published schemas (links in step 1)
Code
sox_access_evidence.py

When this is not enough

  • You need every quarter, not one run. The script is a point-in-time pull. Keep each pull’s settings and the lists as produced.
  • Your platforms are not these three, or your approvals live in a system you cannot export. The approvals test is only as good as the approval log.
  • The control is more than a list. The policy, the review and the sign-off sit in your ticket and HR systems, not in the platforms’ records.
  • Legal and audit advice are not here. Whether a list is enough is for your auditor.

See every access change in one place

Data Control Tower deploys into your own Azure subscription from a template your IT team can read before it runs. Every day it reads the records your data platforms already keep, and it never writes to them. It lists every access change and who made it, including access someone gave by hand. Leavers are checked against your HR file, across every platform you connect. No platform passwords stored.

The Governance Evidence Pack, in early access, turns those checks into evidence for SOX or HIPAA. For SOX it flags anyone who does both (changes financial data and approves access to it), using the same access records. We produce the evidence. Your auditor decides.

See what Data Control Tower reads, and why it never writes to your platforms.

See Data Control Tower