SOC 2 user access evidence for Snowflake, Databricks and Redshift: three lists and a check for missing users
The short answer
In our reading, SOC 2 criteria CC6.1, CC6.2 and CC6.3 are about three things you can list: the user accounts, the administrators, and the successful sign-ins by method. Your auditor decides what to sample, so ask for their request. This guide shows a free script that builds all three from Snowflake, Databricks and Redshift exports, on demo records.
What the criteria cover, in our words
The AICPA’s Trust Services Criteria are licensed text, so this page names criteria by id and describes them in our own words; it quotes none of them (AICPA: 2017 Trust Services Criteria (With Revised Points of Focus, 2022) (read 2026-11-01)). CC6.1 is about logical access security, including identifying and authenticating users. Multifactor authentication appears in one of its points of focus, as an example of stronger authentication where an organization’s own risk strategy calls for it, so this page never says SOC 2 requires it: your own policy decides, which is why the sign-in policy in the script is a setting with no default. CC6.2 is about registering and authorizing users before they are given credentials, and removing credentials when access is no longer authorized; its points of focus include reviewing credentials for validity, including system or service accounts that should not be there. CC6.3 is about authorizing, changing and removing access according to people’s roles and duties, or how the system is built, with regard to least privilege and segregation of duties.
Points of focus describe characteristics, not requirements. We do not know what any particular auditor will sample, and this page does not say. What follows is our reading: those three criteria are about who has an account, who can administer, and how people authenticate, so each of those is a list you can build from the platforms’ own records, and the first list is only worth showing if you can also show it is not missing anyone. Ask your auditor for their sample request, then check it against the lists below. Approvals before a grant and the timing of a leaver’s removal are in our SOX access evidence guide; this page does not repeat them.
What you will have at the end
A Python script, soc2_user_access_evidence.py, that reads each platform’s own account and sign-in records, exported as CSV with the platform’s published column names, plus an HR file, a login map, a service-account owner list, an approved-administrator list and your own sign-in policy. It runs twenty-nine input checks first, then prints 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, and the result line names it. 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. The script produces evidence; it does not say a criterion is met or that anyone will accept it.
What you will need
- Access to the platforms’ own records. On Redshift, SYS_CONNECTION_LOG is
visible only to superusers
, and SVV_USER_INFO shows every row only to a superuser, so export both as a superuser (Amazon Redshift: SYS_CONNECTION_LOG (read 2026-11-01); Amazon Redshift: SVV_USER_INFO (read 2026-11-01)). An empty connection-log export fails an input check. On Snowflake, the ACCOUNT_USAGE views USERS, LOGIN_HISTORY and GRANTS_TO_USERS can each lag by up to 120 minutes, so take the export some hours after the last event you need (Snowflake: USERS (read 2026-11-01); Snowflake: LOGIN_HISTORY (read 2026-11-01); Snowflake: GRANTS_TO_USERS (read 2026-11-01)). - Your HR file, a login map, a list of service accounts with an owner each, a list of approved administrators, and your sign-in policy.
- Python 3 and the
duckdbmodule (we ran it with duckdb 1.5.6 and, for the clean run, 2.0.0).
Step 1. Export each platform’s records 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. Databricks’ list-users API returns userName, active and groups for each user, at most 100 per page, so page through with startIndex until you have them all (Databricks: list users (SCIM) (read 2026-11-01)); flatten the groups to their names. Service principals are a separate SCIM resource and are not in this export, so they are not in the listing or the administrators list, and account admins hold a separate role, not membership of the workspace admins group. Its audit log carries sign-in events under the accounts service (samlLogin, oidcBrowserLogin, tokenLogin and others; on Azure Databricks also aadBrowserLogin and aadTokenLogin, Azure Databricks: audit log reference (read 2026-11-01)). The script’s list of sign-in actions is a setting, DATABRICKS_LOGIN_ACTIONS, and an accounts action that looks like a sign-in but is not on it fails an input check. Creating a token is logged as generateDbToken, with the parameters tokenExpirationTime, tokenCreatedBy, tokenHash and userId (Databricks: audit log reference (read 2026-11-01)).
| File | Columns the script reads | Defined in |
|---|---|---|
snowflake_users.csv | NAME, LOGIN_NAME, EMAIL, TYPE, DISABLED, CREATED_ON, DELETED_ON, HAS_PASSWORD, HAS_MFA, HAS_RSA_PUBLIC_KEY, LAST_SUCCESS_LOGIN, OWNER | Snowflake: USERS (read 2026-11-01) |
snowflake_login_history.csv | USER_NAME, EVENT_TIMESTAMP, IS_SUCCESS, FIRST_AUTHENTICATION_FACTOR, SECOND_AUTHENTICATION_FACTOR | Snowflake: LOGIN_HISTORY (read 2026-11-01) |
snowflake_grants_to_users.csv | CREATED_ON, DELETED_ON, ROLE, GRANTEE_NAME, GRANTED_BY | Snowflake: GRANTS_TO_USERS (read 2026-11-01) |
databricks_scim_users.csv | userName, active, groups (the group names of each user, separated by semicolons: our flattening of the SCIM groups list) | Databricks: list users (SCIM) (read 2026-11-01) |
databricks_audit.csv | event_time, user_identity, service_name, action_name, request_params, response (user_identity, request_params and response as JSON text; service_name and action_name as plain text) | Databricks: audit log system table (read 2026-11-01); Databricks: audit log reference (read 2026-11-01) |
redshift_svv_user_info.csv | user_name, user_id, superuser, external_user_id | Amazon Redshift: SVV_USER_INFO (read 2026-11-01) |
redshift_sys_connection_log.csv | event, record_time, user_name, auth_method | Amazon Redshift: SYS_CONNECTION_LOG (read 2026-11-01) |
hr_people.csv | email and last_day: one row per person, last_day written YYYY-MM-DD and empty while the person works here | yours (the two column names are settings) |
login_map.csv | platform, login, email: one row for every login that is not itself an email address and has no email in the platform's own record | yours |
service_owners.csv | platform, login, owner_email: one row for every service account; an empty owner is flagged | yours |
approved_admins.csv | platform, login: who your organization approved as an administrator, as the platform names the login | yours |
What each platform keeps decides what you can show. Snowflake’s LOGIN_HISTORY covers the last 365 days (one year), so a longer span needs your own copy of it (Snowflake: LOGIN_HISTORY (read 2026-11-01)). The USERS page gives no retention period of its own; it says DELETED_ON is the time the account was deleted (Snowflake: USERS (read 2026-11-01)). We read Databricks’ and Redshift’s user lists as the users that exist on the day you export; we found no deleted-user history on either page, so a removed account may not be in them. We found no retention period on the SYS_CONNECTION_LOG page (Amazon Redshift: SYS_CONNECTION_LOG (read 2026-11-01)), so the day-by-day check below, not a documented figure, is what tells you whether your export reaches back far enough.
Step 2. Write your policy and your lists, and read every timestamp strictly
Three settings have no default, and the script stops and says so if one is unset, because a value you never chose would be ours, not yours: ADMIN_ROLES (which Snowflake roles are administrator rights), POLICY (which sign-in methods your policy allows, per platform and for a person or a service account), and MAX_TOKEN_DAYS (how long your policy lets a Databricks personal access token last). The method strings in the demo policy are examples: Snowflake’s page does not list the values of its two factor columns, and Redshift’s does not list the values of auth_method, so list your own with the sign-in list below before you write the policy. A Databricks method is the audit log’s action name. Timestamps are read as YYYY-MM-DD HH:MM:SS in UTC (a T in place of the space and fractional seconds are also accepted), with an optional Z, +00:00 or +0000 suffix; any other form is counted and fails an input check, never dropped. The settings you change sit at the top of the script:
# ---- CHANGE THESE FOR YOUR OWN DATA ---------------------------------------------------------
PERIOD_START = "2026-07-01" # first day of the review period (UTC, inclusive)
PERIOD_END = "2026-09-30" # last day of the review period (UTC, inclusive)
AS_OF = "2026-10-15" # the day you took the exports (UTC); records must run to here
EXPORT_DIR = None # folder holding the export files named in FILES below
PLATFORMS_EXPORTED = ["snowflake", "databricks", "redshift"] # a platform not listed is NOT CHECKED
ADMIN_ROLES = None # no default: Snowflake roles that are administrator rights, e.g. ["ACCOUNTADMIN", "SECURITYADMIN"]
DATABRICKS_ADMIN_GROUPS = ["admins"] # SCIM group names whose members are administrators
POLICY = None # no default: your sign-in policy, {platform: {"person": [methods], "service": [methods]}}
MAX_TOKEN_DAYS = None # no default: longest lifetime your policy allows a Databricks personal access token
KNOWN_GAPS = {} # days you can explain, e.g. {"redshift": ["2026-08-19"]}
HR_EMAIL_COL = "email" # HR file: the work email
HR_LAST_DAY_COL = "last_day" # HR file: the last day of work as YYYY-MM-DD, empty while the person works here
REDSHIFT_SIGNIN_EVENT = "authenticated" # the SYS_CONNECTION_LOG event that is a successful sign-in
DATABRICKS_LOGIN_ACTIONS = ["login", "samlLogin", "oidcBrowserLogin", "tokenLogin", "jwtLogin", "mfaLogin", "aadBrowserLogin",
"aadTokenLogin", "multiFactorAuthenticationLogin", "oidcTokenAuthorization", "certLogin",
"workspaceLoginCodeAuthentication", "accountLoginCodeAuthentication",
"accountlessToAccountLoginAuthentication", "accountInHouseOAuthClientAuthentication"] # accounts-service actions that are a sign-in
# ----------------------------------------------------------------------------------------------
Step 3. What each list does
The user listing has one row per account that existed at any time in the period. Snowflake rows run from CREATED_ON to DELETED_ON. We treat a blank or PERSON type as a person and any other Snowflake type as a service account (the USERS page names SERVICE and SNOWFLAKE_SERVICE among its types and links the full list; this rule is ours, so check your own TYPE values; Snowflake: USERS (read 2026-11-01)). Any login in your owners file is a service account, on every platform. A person is matched to HR by the login map, then by a login that is itself an email, then by the email the platform holds. The flags: NO PERSON (no HR match), LEFT BUT ENABLED (an HR leaver whose account is still enabled at AS_OF: the timing is the SOX guide’s), SERVICE NO OWNER (no owner, or an owner HR does not know or who has left).
The completeness check is the part to show first. The script takes every login seen in a successful sign-in during the period and looks for it in the listing. One that is not there is LOGIN NOT IN LISTING: on Snowflake, whose USERS view keeps deleted accounts with their DELETED_ON, the likely cause is a filtered export; on Databricks and Redshift it can also be an account removed since, and on Databricks a service principal, which the users export does not hold. On Databricks and Redshift, whose lists show only today’s accounts, a login that HR says has left is printed as LEFT, NO LONGER LISTED, which is not counted as an exception. The check is this code:
seen_logins = sorted({(p, l) for p, l, _, _ in signins if (p, l) not in by_login})
for p, l in seen_logins:
who_ = person_of(p, l, "")
status = "LEFT, NO LONGER LISTED" if p != "snowflake" and who_ in leaver else "LOGIN NOT IN LISTING"
rows1.append((p, l, "(not in listing)", who_ or "", "", n_in[(p, l)], status))
The administrators list is who holds an administrator right at the end of AS_OF: a Snowflake role named in ADMIN_ROLES (GRANTS_TO_USERS rows still open), a Redshift superuser, or an active member of the Databricks admins group, compared with your approved list (NOT ON ADMIN LIST). A second list gives the Snowflake administrator grants made in the period with their date and the granting role; GRANTED_BY is a role, not a person (Snowflake: GRANTS_TO_USERS (read 2026-11-01)).
The sign-in list counts every successful sign-in in the period per login and method (Snowflake IS_SUCCESS is yes, a Databricks status of 200, a Redshift authenticated event) and compares the method with the ones your policy allows for that account type: OUTSIDE POLICY. A Databricks token created in the period with no expiry, or one lasting longer than MAX_TOKEN_DAYS, is OUTSIDE POLICY too; the audit log also records tokens the service itself generates under the same action, so check who created each. A sign-in or a token after the person’s HR last day is AFTER LAST DAY, whether or not the account is still listed.
Step 4. Run it on the clean generated records
Run python3 soc2_user_access_evidence.py --demo. It writes the generated exports to a folder called generated and its lists to evidence-out. The output below is the end of the run: all twenty-nine input checks printed [ok], and a list prints only the rows to explain when it has more than twelve rows. The only row printed is a Redshift user HR says has left and the listing no longer shows, which is informational.
== User listing (accounts during the period): soc2_1_user_listing
period : 2026-07-01 to 2026-09-30 (UTC, inclusive); accounts and administrators as of 2026-10-15
filters : every account that existed at any time in the period (Snowflake: CREATED_ON to DELETED_ON; Databricks and Redshift export only the accounts that exist at the export, so a removed account is not listed), plus any login seen signing in that the listing lacks; one row per account
run at : 2026-11-01 12:06:04 UTC
rows : 18
sha256 : 0ebe371093250d154f626069812b51e034b6b9959fb877cdf0241af3738e99ec
redshift | eli | (not in listing) | p06@example.test | - | 8 | LEFT, NO LONGER LISTED
== Administrators at AS_OF against the approved list: soc2_2_administrators
period : 2026-07-01 to 2026-09-30 (UTC, inclusive); accounts and administrators as of 2026-10-15
filters : Snowflake: users holding a role named in ADMIN_ROLES (GRANTS_TO_USERS rows open at the end of AS_OF); Redshift: superusers; Databricks: active members of admins; one row per account and right
run at : 2026-11-01 12:06:04 UTC
rows : 4
sha256 : c8f436efb4bf9bc2c3f2f6cee2c29d0f7c6cedf1ee90ebec3e3316c6a488046b
databricks | p01@example.test | p01@example.test | group admins | - | approved
redshift | ana | p01@example.test | superuser | - | approved
snowflake | p01 | p01@example.test | role ACCOUNTADMIN | 2026-01-05 | approved
snowflake | p02 | p02@example.test | role SECURITYADMIN | 2026-08-12 | approved
== Administrator role grants made in the period: soc2_2b_admin_grants_in_period
period : 2026-07-01 to 2026-09-30 (UTC, inclusive); accounts and administrators as of 2026-10-15
filters : Snowflake GRANTS_TO_USERS rows for ADMIN_ROLES with CREATED_ON in the period; the granter is a role, not a person; Databricks and Redshift: these exports do not record who made a grant
run at : 2026-11-01 12:06:04 UTC
rows : 1
sha256 : 63daf5720760ea655da872b577d48ae3a5b24face2bfeeda7dc43bdddb2d34c4
2026-08-12 | SECURITYADMIN | p02 | ACCOUNTADMIN | yes
== Successful sign-ins by method, and tokens created, against your policy: soc2_3_signins_against_policy
period : 2026-07-01 to 2026-09-30 (UTC, inclusive); accounts and administrators as of 2026-10-15
filters : successful sign-ins in the period (Snowflake IS_SUCCESS, Databricks status 200, Redshift event authenticated), one row per login and method; the allowed methods come from POLICY by account type; AFTER LAST DAY counts sign-ins after the person's HR last day; a token is OUTSIDE POLICY with no expiry or a lifetime over 90 days
run at : 2026-11-01 12:06:04 UTC
rows : 20
sha256 : 5d6a29f31063c94126c57fe4a8eb4993a9d797c6ba5f5e4bb9b85c8387c86402
== Result
input checks : all passed
not checked : none
follow-up : 0 account row(s) to explain, 0 administrator row(s) not on the approved list, 0 sign-in or token row(s) outside policy or after a last day
RESULT: NOTHING TO FOLLOW UP
The same clean run on duckdb 2.0.0 printed the same four SHA-256 values, so the fingerprints do not depend on the DuckDB release (shown: the user listing’s).
== User listing (accounts during the period): soc2_1_user_listing
period : 2026-07-01 to 2026-09-30 (UTC, inclusive); accounts and administrators as of 2026-10-15
filters : every account that existed at any time in the period (Snowflake: CREATED_ON to DELETED_ON; Databricks and Redshift export only the accounts that exist at the export, so a removed account is not listed), plus any login seen signing in that the listing lacks; one row per account
run at : 2026-11-01 12:06:14 UTC
rows : 18
sha256 : 0ebe371093250d154f626069812b51e034b6b9959fb877cdf0241af3738e99ec
redshift | eli | (not in listing) | p06@example.test | - | 8 | LEFT, NO LONGER LISTED
Step 5. Run it again with faults planted in the records
Run python3 soc2_user_access_evidence.py --demo --plant. The generated records now hold a former employee still enabled on all three platforms, a login that signs in but is missing from the listing, a service account with no owner, two accounts matching no person (one on Snowflake, one on Databricks), a former employee who signs in on Redshift after the last day, an administrator nobody approved on each platform, password and token sign-ins your policy does not allow, and three faults in the exports themselves. The input checks catch those three and mark every list INCOMPLETE:
== Input checks
[ok] HR file: rows with no email: 0 rows
[FAIL] HR file: last days not written as YYYY-MM-DD: 1 rows
[ok] login map: rows with no login or no email: 0 rows
[ok] USERS: empty export: 0 export
[ok] USERS: rows with no NAME: 0 rows
[ok] USERS: CREATED_ON not written as YYYY-MM-DD HH:MM:SS: 0 rows
[ok] USERS: DELETED_ON or LAST_SUCCESS_LOGIN not written as YYYY-MM-DD HH:MM:SS: 0 rows
[ok] USERS: DISABLED is not true or false: 0 rows
[ok] LOGIN_HISTORY: EVENT_TIMESTAMP or IS_SUCCESS unreadable, or a successful sign-in with no USER_NAME: 0 rows
[ok] GRANTS_TO_USERS: dates unreadable: 0 rows
[ok] ADMIN_ROLES: a name that matches no grant in GRANTS_TO_USERS (a typo hides every holder): 0 names
[FAIL] LOGIN_HISTORY: a user's LAST_SUCCESS_LOGIN day has no successful sign-in on it (filtered or short export): 1 users
[ok] SCIM users: empty export: 0 export
[ok] SCIM users: userName empty or listed twice: 0 rows
[ok] SCIM users: active is not true or false: 0 rows
[ok] system.access.audit: event_time not written as YYYY-MM-DD HH:MM:SS: 0 rows
[ok] system.access.audit: user_identity that is not JSON: 0 rows
[ok] system.access.audit: accounts actions that look like a sign-in but are not in DATABRICKS_LOGIN_ACTIONS: 0 actions
[ok] system.access.audit: sign-in rows with no readable user or request_params: 0 rows
[ok] system.access.audit: sign-in or token rows with no readable status: 0 rows
[ok] system.access.audit: token rows with no creator or an expiry that is not a number: 0 rows
[ok] SVV_USER_INFO: empty export: 0 export
[ok] SVV_USER_INFO: user_name empty or listed twice: 0 rows
[ok] SVV_USER_INFO: superuser is not true or false: 0 rows
[ok] SYS_CONNECTION_LOG: empty export (a non-superuser sees no rows): 0 export
[ok] SYS_CONNECTION_LOG: record_time not written as YYYY-MM-DD HH:MM:SS, or no user on a sign-in: 0 rows
[ok] snowflake: days with no sign-in record from 2026-07-01 to 2026-10-15: 0 days
[ok] databricks: days with no sign-in record from 2026-07-01 to 2026-10-15: 0 days
[FAIL] redshift: days with no sign-in record from 2026-07-01 to 2026-10-15: 1 days: 2026-08-19
[note] a failed check means the lists below may be incomplete: fix the export or explain the gap in KNOWN_GAPS, then run again
The user listing then shows the rows to explain. The login sf_ghost signed in seven times and is not in the Snowflake users export:
== User listing (accounts during the period): soc2_1_user_listing ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive); accounts and administrators as of 2026-10-15
filters : every account that existed at any time in the period (Snowflake: CREATED_ON to DELETED_ON; Databricks and Redshift export only the accounts that exist at the export, so a removed account is not listed), plus any login seen signing in that the listing lacks; one row per account
run at : 2026-11-01 12:06:08 UTC
rows : 21
sha256 : fc0298281701fad612427135132afea5c0fd45ab370eed93dda12ec64ebabe26
databricks | p06@example.test | person | p06@example.test | yes | 7 | LEFT BUT ENABLED
databricks | temp.consultant@example.test | person | temp.consultant@example.test | yes | 0 | NO PERSON
redshift | eli | person | p06@example.test | yes | 9 | LEFT BUT ENABLED
redshift | etl_loader | service | - | yes | 91 | SERVICE NO OWNER
snowflake | contractor1 | person | - | yes | 1 | NO PERSON
snowflake | p06 | person | p06@example.test | yes | 8 | LEFT BUT ENABLED
snowflake | sf_ghost | (not in listing) | - | - | 7 | LOGIN NOT IN LISTING
The administrators list shows one administrator on each platform who is not on the approved list, and the Snowflake grant list dates the Snowflake one:
== Administrators at AS_OF against the approved list: soc2_2_administrators ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive); accounts and administrators as of 2026-10-15
filters : Snowflake: users holding a role named in ADMIN_ROLES (GRANTS_TO_USERS rows open at the end of AS_OF); Redshift: superusers; Databricks: active members of admins; one row per account and right
run at : 2026-11-01 12:06:08 UTC
rows : 7
sha256 : b310e10ee529726a434def3d8d768585217325055d6e117db7c967cb7cb1220a
databricks | p01@example.test | p01@example.test | group admins | - | approved
databricks | p03@example.test | p03@example.test | group admins | - | NOT ON ADMIN LIST
redshift | ana | p01@example.test | superuser | - | approved
redshift | dee | p04@example.test | superuser | - | NOT ON ADMIN LIST
snowflake | p01 | p01@example.test | role ACCOUNTADMIN | 2026-01-05 | approved
snowflake | p02 | p02@example.test | role SECURITYADMIN | 2026-08-12 | approved
snowflake | p03 | p03@example.test | role ACCOUNTADMIN | 2026-09-02 | NOT ON ADMIN LIST
== Administrator role grants made in the period: soc2_2b_admin_grants_in_period ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive); accounts and administrators as of 2026-10-15
filters : Snowflake GRANTS_TO_USERS rows for ADMIN_ROLES with CREATED_ON in the period; the granter is a role, not a person; Databricks and Redshift: these exports do not record who made a grant
run at : 2026-11-01 12:06:08 UTC
rows : 2
sha256 : e33edd2026eba487ec7d65341eff6cebf2e3f1f7c4c2f30b03b0ff7f285aaade
2026-08-12 | SECURITYADMIN | p02 | ACCOUNTADMIN | yes
2026-09-02 | ACCOUNTADMIN | p03 | ACCOUNTADMIN | yes
The sign-in list shows the methods and tokens outside the demo policy and the sign-in after a last day, and the result line adds up the follow-up:
== Successful sign-ins by method, and tokens created, against your policy: soc2_3_signins_against_policy ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive); accounts and administrators as of 2026-10-15
filters : successful sign-ins in the period (Snowflake IS_SUCCESS, Databricks status 200, Redshift event authenticated), one row per login and method; the allowed methods come from POLICY by account type; AFTER LAST DAY counts sign-ins after the person's HR last day; a token is OUTSIDE POLICY with no expiry or a lifetime over 90 days
run at : 2026-11-01 12:06:08 UTC
rows : 27
sha256 : 089b0388a5b678c1938e953b58e961ed76bbb2d9e289222873cc6a90cf2ae016
databricks | p02@example.test | person | token created | generateDbToken | 1 | 2026-09-15 | 2026-09-15 | no expiry | OUTSIDE POLICY
databricks | p04@example.test | person | sign-in | tokenLogin | 1 | 2026-09-25 | 2026-09-25 | - | OUTSIDE POLICY
databricks | svc-jobs@example.test | service | token created | generateDbToken | 1 | 2026-09-20 | 2026-09-20 | lasts 365 days | OUTSIDE POLICY
redshift | cy | person | sign-in | password | 6 | 2026-09-01 | 2026-09-26 | - | OUTSIDE POLICY
redshift | eli | person | sign-in | iam | 9 | 2026-07-01 | 2026-08-20 | 1 after last day 2026-08-07 | AFTER LAST DAY
snowflake | p03 | person | sign-in | PASSWORD | 4 | 2026-09-11 | 2026-09-26 | - | OUTSIDE POLICY
== Result
input checks : FAILED, the lists above are not complete
not checked : none
follow-up : 7 account row(s) to explain, 3 administrator row(s) not on the approved list, 6 sign-in or token row(s) outside policy or after a last day
RESULT: EXCEPTIONS FOUND
| Input check | Planted fault | Tripped in |
|---|---|---|
| HR file: last days not written as YYYY-MM-DD | one person's last day written 8/5/26 | planted run |
| LOGIN_HISTORY: a user's LAST_SUCCESS_LOGIN day has no successful sign-in on it | one user's sign-ins from 20 September left out of the login export, while USERS still holds the last one | planted run |
| A day with no sign-in record, per platform | every Redshift connection record of 2026-08-19 removed | planted run, and the second generated set (see Run it on your own data) |
| HR file: rows with no email; login map: rows with no login or no email | a blank email in each file | the second generated set (see Run it on your own data) |
The other twenty-four (the Snowflake and Databricks day checks only passed): empty exports, a user_identity that is not JSON, an accounts action that looks like a sign-in but is not in DATABRICKS_LOGIN_ACTIONS, rows with no name, unreadable dates, DISABLED, active or superuser that is not true or false, a userName empty or listed twice, unreadable audit rows, a token expiry that is not a number, an ADMIN_ROLES name that matches no grant | each is written to fail on the fault its label names (for example an ADMIN_ROLES entry spelled SECURITYADMN that no grant uses); we did not trigger them in the runs shown | none of the runs shown |
How to read the results
- INCOMPLETE on a list means an input check failed: the list may be missing rows. Fix the export, or explain a quiet day in
KNOWN_GAPS, before you show it to anyone. - NO PERSON is an account HR cannot match. It may be a contractor or a missing login-map row; the list cannot tell which.
- LEFT BUT ENABLED is an HR leaver whose account is still enabled. SERVICE NO OWNER is a service account nobody answers for.
- LOGIN NOT IN LISTING is a login that signed in but the listing lacks. On Snowflake the likely cause is a filtered export; on Databricks and Redshift it can also be an account deleted since; find out which before you show the listing as complete.
- LEFT, NO LONGER LISTED is a Databricks or Redshift login that HR says has left and the current user list no longer shows. It is informational and is not counted.
- NOT ON ADMIN LIST is an administrator your approved list does not name. The list may be out of date, so it is a question, not a verdict.
- OUTSIDE POLICY is a sign-in method or a token your own policy does not allow for that account type. The script compares strings with your policy; whether the policy is right is yours to decide.
- AFTER LAST DAY is a sign-in or token after the person’s HR last day. It is counted whether or not the account is still listed.
- Keep each run’s settings, its SHA-256 lines and the CSVs it wrote. Your auditor decides what they show.
Run it on your own data
To run it on your own data, change only the settings block at the top of the download. Set EXPORT_DIR to the folder holding your exports. Set PERIOD_START, PERIOD_END and AS_OF. Set PLATFORMS_EXPORTED to the platforms you exported. Set ADMIN_ROLES to your Snowflake administrator roles, write your sign-in policy into POLICY, and set MAX_TOKEN_DAYS if you export Databricks. If your HR file's headings are not email and last_day, set HR_EMAIL_COL and HR_LAST_DAY_COL. If a file has another name, change its entry in FILES. Export each platform file with the platform's own column names and timestamps as YYYY-MM-DD HH:MM:SS in UTC, and keep the headings the script names in the login map, service owner and approved administrator files.
For each login that is not an email address and has no email in the platform’s own record, add a row to the login map. Change nothing else, and run it without --demo.
We tested those steps on a second, separate set of generated files: other file names, an HR file with other headings, names written in mixed case, a mixed-case Snowflake login against upper-case history, a blank Snowflake email that the login map fills in, a blank email in the HR file and in the login map, and a period of one month. The script caught the planted former employee, the account with no person, the service account with no owner, the login missing from the listing, the administrator on Snowflake and on Redshift that nobody approved, a Snowflake password sign-in, a Databricks token with no expiry and the Redshift day with no records, and it ran to the end. That test is also generated data.
Download soc2_user_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 (230). The HR file, login map, owner list and approved list are generated too. The people, logins and accounts are invented.
- Period: 2026-07-01 to 2026-09-30, UTC, inclusive, for the sign-ins and the accounts. Administrators and enabled accounts are read as of the end of
AS_OF(2026-10-15). Completeness is checked for every day from the period’s start toAS_OFon each platform. - A successful sign-in is a Snowflake LOGIN_HISTORY row with IS_SUCCESS yes, a Databricks
accountsaudit row whose action is inDATABRICKS_LOGIN_ACTIONSwith a status of 200, or a Redshift SYS_CONNECTION_LOG row with the eventauthenticated. A refused attempt is not counted. - Who is a service account: any login in your owner file first, then a Snowflake user whose type is not blank or PERSON. Everything else is a person, in every list.
- The demo policy is an example, not a recommendation of what any account type should use: people sign in by single sign-on, IAM or a password with a second factor; service accounts by key pair, token, IAM or (on Redshift) a password; and tokens last at most ninety days.
- Unreadable input is counted, not dropped: a failed input check marks every list INCOMPLETE. The Limits below list what the script cannot see.
- Input checks in the runs: of the twenty-nine input checks, five failed in the runs shown; the other twenty-four are written to fail on the fault their labels name and were not triggered.
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. These are our assumptions, so open one of your own rows and check: Snowflake’s LOGIN_HISTORY
USER_NAMEis the user’sNAMEin USERS; the Databricks sign-in event carries the user inrequest_paramsunderuser;tokenExpirationTimeis Unix time in milliseconds, with an empty or zero value meaning no expiry (the Databricks page names the parameter but gives neither its format nor a default token lifetime; Databricks: audit log reference (read 2026-11-01)); a successful Databricks sign-in hasstatusCode200 inresponse; a non-superuser’s export of SYS_CONNECTION_LOG comes back empty; the user lists of Databricks and Redshift hold only accounts that still exist; and a blank or PERSON Snowflake type is a person, any other a service account. - The criteria are not a checklist. We describe CC6.1 to CC6.3 in our own words and do not quote them. Which lists an auditor asks for, and in what form, is theirs to say.
- The listing says who had an account, not whether the access was right. It shows no approvals and no access reviews. We read the Databricks and Redshift lists as of the export day, so a removal is seen only through a login in the sign-in records that HR matches to a leaver. On Redshift, SVV_USER_INFO has no disabled flag, so every listed account counts as enabled. Redshift’s
external_user_idis the identity provider’s identifier (Amazon Redshift: SVV_USER_INFO (read 2026-11-01)); we use it as an email only when it is one, so add Redshift logins to the login map. - Administrators are only those the settings name. A Snowflake role granted to another role, a nested Databricks group, a Databricks account admin (a role, not group membership) and a Redshift role other than superuser are not followed. Databricks service principals are not in the users export at all.
- Admin grants with a date and a granter exist only for Snowflake, and the granter is a role. The Databricks and Redshift exports here do not record who made a grant.
- Tokens: only tokens created in the period are listed. A token created earlier and still valid is not, and a revoked token is not followed.
- Sign-in methods are strings. A Databricks method is the audit log’s action name, and its
authenticationMethodparameter is not read. A Snowflake second factor that is empty means none was recorded. - The empty-day check can fire on a quiet day, and cannot see a day that has some records but not all. On Databricks only
accountsrows count for a day, so make sure your audit export includes that service. - Timestamps with no zone suffix are taken as UTC. Check the time zone of your own export session and set it to UTC before you export.
Tested on
Tested on
- Platform
- DuckDB 1.5.6 (every run) and 2.0.0 (the clean run), Python 3, run from Python with the
duckdbmodule - Edition
- Open-source DuckDB, in-memory. Not run on Snowflake, Databricks or Redshift.
- Date
- 2026-11-01
- Dataset
- Generated records, seed 230 (not a company’s data), in the column names of the platforms’ own published schemas (links in step 1)
- Code
- soc2_user_access_evidence.py
When this is not enough
- You need more than one day’s pull. The script reads exports taken on one day. Keep each pull’s settings and the lists as produced, and take the exports often enough that your platforms’ own retention never leaves a gap.
- Your platforms are not these three, or you cannot export the records named in step 1.
- The criteria are more than three lists. Approvals, reviews and the policy itself sit in your own systems, not in the platforms’ records.
- Audit advice is not here. Whether a list is enough is for your auditor.
See leavers and access changes every day
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, and checks leavers against your HR file, across every platform you connect.