Check recipe · Account management evidence

NIST SP 800-53 AC-2 evidence for Snowflake, Databricks, Redshift

DuckDB2026-11-01Script and PDF downloadable

The short answer

AC-2 sets the questions and leaves every time period to you. Our free script builds four lists from platform records: enabled accounts with no recent sign-in, temporary accounts past their end date, transfers that kept old access, and the account changes it could read in the records. We ran it on generated records. It produces evidence; your assessor decides.

What AC-2 says, and what it leaves to you

NIST SP 800-53 Rev. 5 is a catalog of security and privacy controls. This page quotes control AC-2, Account Management, from the NIST OSCAL catalog of SP 800-53 Rev. 5, release 5.2.0 (read 2026-11-01), word for word except AC-2 h.2, which is summarized around a quoted fragment. Each item in square brackets is a parameter, shown with the catalog's own label: a value the organization defines. NIST does not fill it in, and neither does this page. The enhancements, such as AC-2(2) and AC-2(3), apply only where your selected baseline includes them; this page does not know which baseline you have.

  • AC-2 f: Create, enable, modify, disable, and remove accounts in accordance with [policy, procedures, prerequisites, and criteria];
  • AC-2 g: Monitor the use of accounts;
  • AC-2 h.2: account managers are notified within a time period you set when users are terminated or transferred.
  • AC-2 l: Align account management processes with personnel termination and transfer processes.
  • AC-2(2), Automated Temporary and Emergency Account Management: Automatically [remove; disable] temporary and emergency accounts after [time period].
  • AC-2(3), Disable Accounts: Disable accounts within [time period] when the accounts: (a) Have expired; (b) Are no longer associated with a user or individual; (c) Are in violation of organizational policy; or (d) Have been inactive for [time period].
  • AC-2(4), Automated Audit Actions: Automatically audit account creation, modification, enabling, disabling, and removal actions.

So the evidence has two halves. The platforms' own records show what happened: who is enabled, who signed in, what changed. Your policy says what should have happened: how many days of inactivity, how long a temporary account may outlive its end date, how quickly a transfer loses the old team's access. The script reads the first half and asks you for the second. It never picks a number for you.

Two neighbors are already published. When a leaver's access ended and who approved a grant are in the SOX access guide. The user listing, administrators and sign-in methods are in the SOC 2 user access guide. This page covers what AC-2 adds: inactivity, temporary accounts, transfers, and the trail of account actions.

What you will have at the end

A Python script, ac2_account_evidence.py, that reads CSV exports of each platform's own account records, with the platform's published column names, plus lists you keep. It runs 17 input checks first (fewer if you leave a platform out), then prints four 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. The details are also in a PDF: Download the full guide (PDF).

The four lists
ListAC-2 text it gives evidence onWhat it flags
1. Inactive but enabledAC-2(3)(d)An enabled account with no sign-in for INACTIVE_DAYS, counted from its last sign-in, or from its creation if the records hold no sign-in.
2. Temporary and emergency accounts past their endAC-2(2), AC-2(3)(a)A listed account that is still enabled after its end date plus TEMP_GRACE_DAYS (STILL ENABLED PAST END), was used after it (USED AFTER END), or was ended later (ENDED LATE).
3. Transfers that kept the old accessAC-2 h.2 and lFor each HR transfer, a role or group your map ties to the old department that the person still holds TRANSFER_DAYS after the transfer (KEPT OLD ACCESS).
4. Account actions in the periodAC-2(4)Each successful create, modify, enable, disable and remove it reads from the platform records, with who did it where the record says. Also each change ticket with no recorded action (NOT RECORDED). The first is the full set of recorded actions, not a finding.

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 control is met or that anyone will accept it.

What you will need

  • Snowflake: ACCOUNT_USAGE.USERS and GRANTS_TO_USERS can lag by up to 120 minutes (Snowflake: ACCOUNT_USAGE.USERS (read 2026-11-01); Snowflake: ACCOUNT_USAGE.GRANTS_TO_USERS (read 2026-11-01)), and QUERY_HISTORY by up to 45 minutes and covers the last 365 days; its start_time is in the local time zone, so export from a session set to UTC (Snowflake: ACCOUNT_USAGE.QUERY_HISTORY (read 2026-11-01)). Query text longer than 100K characters is truncated. Snowflake's EXPIRES_AT is the date and time when the user's status is set to EXPIRED and the user can no longer log in (Snowflake: ACCOUNT_USAGE.USERS (read 2026-11-01)).
  • Databricks: the audit table has the columns event_time, event_date, user_identity, service_name, action_name, request_params and response, and account-level events are recorded with workspace_id 0 (Databricks: audit log system table (read 2026-11-01)). The actions we read, such as add, deactivateUser and addPrincipalToGroup, are named on the audit log reference (Databricks: audit log reference (read 2026-11-01); Azure Databricks: audit log reference (read 2026-11-01), which adds the Microsoft Entra ID sign-ins aadBrowserLogin and aadTokenLogin). Most of them are logged twice over: at account level, where add means a user is added to the account, and at workspace level, where it means a user is added to one workspace. Databricks keeps system.access.audit for 365 days, Regional for workspace-level events. Global for account-level events. (Databricks: system tables overview (read 2026-11-01)). The SCIM users list gives id, userName and active but has no groups field (Databricks: list account users (read 2026-11-01)), and the groups list no longer returns members: you get them from each group's details, where each member has a value (Databricks: list account groups (read 2026-11-01)).
  • Redshift: SYS_USERLOG and SYS_CONNECTION_LOG are visible only to superusers (Amazon Redshift: SYS_USERLOG (read 2026-11-01); Amazon Redshift: SYS_CONNECTION_LOG (read 2026-11-01)). SVV_USER_INFO is visible to all users, but only superusers see all rows (Amazon Redshift: SVV_USER_INFO (read 2026-11-01)), and its page lists no column for a disabled state, so the script treats every user it lists as enabled. SVV_USER_GRANTS lists roles explicitly granted to users, visible to superusers and users with the ACCESS SYSTEM TABLE permission (Amazon Redshift: SVV_USER_GRANTS (read 2026-11-01)). AWS's reference says STL system views retain seven days of log history (Amazon Redshift: system tables and views reference (read 2026-11-01)), and the SYS_CONNECTION_LOG and SYS_USERLOG pages state no period of their own, so plan for about seven days. For an INACTIVE_DAYS longer than that, build redshift_sys_connection_log.csv, with the three columns in step 1, from the connection log your audit logging writes to Amazon S3 or CloudWatch (Amazon Redshift: database audit logging (read 2026-11-01)), or from daily copies you keep. Otherwise the Redshift day-by-day check fails and every list is marked INCOMPLETE.
  • Your own lists: HR people and transfers, a login map, the temporary and emergency accounts with end dates, a map from each role or group to a department, and your change tickets. The files and their columns are in step 1.
  • Python 3 and the duckdb module (we ran it with duckdb 1.5.6 and 2.0.0).

Step 1. Export each platform's records into one folder

The script reads these sixteen files from EXPORT_DIR. Column names are the platforms' own; the script lower-cases the header, so NAME and name both work, and a missing column fails an input check that names it. Names are compared whole and without case. Timestamps are read as YYYY-MM-DD HH:MM:SS in UTC (a fractional second, a T and a Z, +00:00 or +0000 suffix are accepted; any other offset is not). A value that cannot be read is counted by an input check, never skipped.

The sixteen files
FileColumns the script readsDefined in
snowflake_users.csvname, disabled, created_on, deleted_on, last_success_login, expires_atSnowflake: ACCOUNT_USAGE.USERS (read 2026-11-01)
snowflake_grants_to_users.csvcreated_on, deleted_on, role, grantee_name, granted_bySnowflake: ACCOUNT_USAGE.GRANTS_TO_USERS (read 2026-11-01)
snowflake_query_history.csvquery_id, query_text, query_type, user_name, role_name, start_time, execution_statusSnowflake: ACCOUNT_USAGE.QUERY_HISTORY (read 2026-11-01)
databricks_users.csvid, username, active (the SCIM id, userName and active)Databricks: list account users (read 2026-11-01)
databricks_group_members.csvgroup_name, member_id (one row per member of each group: its displayName and the member's value)Databricks: list account groups (read 2026-11-01)
databricks_audit.csvevent_time, event_date, workspace_id, user_identity, service_name, action_name, request_params, response (maps and structs written as JSON text)Databricks: audit log system table (read 2026-11-01)
redshift_svv_user_info.csvuser_name, user_idAmazon Redshift: SVV_USER_INFO (read 2026-11-01)
redshift_svv_user_grants.csvuser_name, role_nameAmazon Redshift: SVV_USER_GRANTS (read 2026-11-01)
redshift_sys_userlog.csvuser_id, user_name, original_user_name, action, record_timeAmazon Redshift: SYS_USERLOG (read 2026-11-01)
redshift_sys_connection_log.csvevent, record_time, user_nameAmazon Redshift: SYS_CONNECTION_LOG (read 2026-11-01)
hr_people.csvemployee_email, departmentYou keep it: your HR file
hr_transfers.csvemployee_email, transfer_date, old_department, new_departmentYou keep it: your HR file
login_map.csvplatform, login, employee_emailYou keep it: which login belongs to which person
temp_accounts.csvplatform, login, end_date, kind (temporary or emergency)You keep it: your list with end dates
role_departments.csvplatform, role_name, departmentYou keep it: which role or group belongs to which department
change_tickets.csvticket, platform, login, action (create, modify, enable, disable or remove), change_dateYou keep it: your change tickets

Databricks keeps workspace-level events in each workspace's region. Run the audit export in a workspace in each region you use and put all the rows in one file; one region's export lacks the others' sign-ins, and the script cannot tell that any are missing.

Run python3 ac2_account_evidence.py --export-sql to print the queries below. They are what we assume each export looks like; run each one as a user who sees every row, and check one row of each against the linked page.

-- Snowflake (run each as a role that can read SNOWFLAKE.ACCOUNT_USAGE; set the session to UTC first)
ALTER SESSION SET TIMEZONE = 'UTC';
SELECT name, disabled, created_on, deleted_on, last_success_login, expires_at FROM SNOWFLAKE.ACCOUNT_USAGE.USERS;
SELECT created_on, deleted_on, role, grantee_name, granted_by FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_USERS;
-- 120 days is a demo window: go back at least as far as your INACTIVE_DAYS
SELECT query_id, query_text, query_type, user_name, role_name, start_time, execution_status
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY WHERE start_time >= DATEADD(day, -120, CURRENT_DATE);

-- Databricks: system.access.audit (maps and structs written as JSON text); go back at least INACTIVE_DAYS.
-- Workspace-level rows are kept in the workspace's region: run this in a workspace in each region you use
-- and put all the rows in one file.
SELECT event_time, event_date, workspace_id, to_json(user_identity) AS user_identity, service_name, action_name,
       to_json(request_params) AS request_params, to_json(response) AS response
FROM system.access.audit WHERE service_name = 'accounts' AND event_date >= date_sub(current_date(), 120);
-- databricks_users.csv (id, username, active): GET /api/2.0/accounts/{account_id}/scim/v2/Users
--   -> id, userName, active, one row per user
-- databricks_group_members.csv (group_name, member_id): GET .../scim/v2/Groups, then GET .../scim/v2/Groups/{id}
--   for each group (the list no longer returns members) -> displayName and each member's value, one row per member

-- Redshift (as a superuser, or a user who can see every row of these views)
SELECT user_id, user_name, original_user_name, action, record_time FROM sys_userlog;
SELECT event, record_time, user_name FROM sys_connection_log;
SELECT user_name, user_id FROM svv_user_info;
SELECT user_name, role_name FROM svv_user_grants;

Step 2. Set your time periods

AC-2 leaves each time period to the organization, so the script has no default for INACTIVE_DAYS, TEMP_GRACE_DAYS or TRANSFER_DAYS. A list whose setting is blank does not run: it prints NOT RUN, and an input check fails. Here is the settings block at the top of the download. The demo fills in 90, 1 and 14 only so there is something to measure; those are not a recommendation, and nothing on this page says what your number should be.

# ---- CHANGE THESE FOR YOUR OWN DATA ---------------------------------------------------------
PERIOD_START = "2026-09-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-05"                 # 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
INACTIVE_DAYS = None                 # no default: days without a sign-in before an enabled account is flagged
TEMP_GRACE_DAYS = None               # no default: days after a temporary account's end date it may still be live
TRANSFER_DAYS = None                 # no default: days after a transfer by which old-department access must go
KNOWN_GAP_DAYS = {}                  # days you can explain, e.g. {"redshift": ["2026-10-03"]}
# Databricks `accounts` audit actions (AWS and Azure audit log references): which ones are a sign-in, and which
# ones change an account. The kinds apply to account-level rows (workspace_id 0); a workspace-level row of any of
# these actions changes the account's place in one workspace, so it is listed as a modify of that workspace.
DATABRICKS_SIGNIN_ACTIONS = ["aadBrowserLogin", "aadTokenLogin", "accountInHouseOAuthClientAuthentication",
                             "accountLoginCodeAuthentication", "accountlessToAccountLoginAuthentication", "certLogin",
                             "jwtLogin", "login", "mfaLogin", "multiFactorAuthenticationLogin", "oidcBrowserLogin",
                             "oidcTokenAuthorization", "samlLogin", "tokenLogin", "workspaceLoginCodeAuthentication"]
DATABRICKS_ACTION_KIND = {"add": "create", "activateUser": "enable", "deactivateUser": "disable", "delete": "remove",
                          "updateUser": "modify", "setAdmin": "modify", "removeAdmin": "modify",
                          "setAccountAdmin": "modify", "removeAccountAdmin": "modify",
                          "addPrincipalToGroup": "modify", "addPrincipalsToGroup": "modify",
                          "removePrincipalFromGroup": "modify", "removePrincipalsFromGroup": "modify"}
# documented actions whose names look like user management or a sign-in but are not an account change we list
DATABRICKS_OTHER_ACTIONS = ["createGroup", "removeGroup", "updateGroup", "changePassword", "resetPassword",
                            "usernameDomainDenied", "validateEmail", "deleteUser", "changeServicePrincipalAcls",
                            "multiFactorAuthenticationUpdateUserAuthPolicy"]
REDSHIFT_SIGNIN_EVENTS = ["authenticated"]     # SYS_CONNECTION_LOG events that count as a sign-in
# ----------------------------------------------------------------------------------------------

The two lists of Databricks action names are settings too. A documented account action that looks like user management but is not on either list fails an input check, so a new action name never slips by unread.

Step 3. How each list is decided

  • Enabled. Snowflake: DISABLED is not true and EXPIRES_AT is empty or still in the future on the export day. Databricks: SCIM active is true. Redshift: every user in SVV_USER_INFO.
  • Last sign-in. Snowflake: the latest of LAST_SUCCESS_LOGIN and any successful query by that user. Databricks: the latest successful (status 200) sign-in action in the accounts service, at account or workspace level. Redshift: the latest authenticated event in SYS_CONNECTION_LOG. An account with no sign-in in the records is counted from its creation (a create action, or CREATED_ON on Snowflake); with neither, it is flagged, because an input check has already shown the records cover every day back to INACTIVE_DAYS before the export. A Databricks user with no sign-in whose only record is a workspace-level add is flagged too, with that basis shown: the records look the same for a new user added from a workspace and for an old, idle user added to a new workspace, so the script does not treat it as the account's creation.
  • Temporary and emergency accounts. Your list, plus any Snowflake user with an EXPIRES_AT that your list does not name. Status, in this order: USED AFTER END (any sign-in or successful query after the end date plus TEMP_GRACE_DAYS), STILL ENABLED PAST END, ENDED LATE (the first disable or remove action, the Snowflake EXPIRES_AT or the Snowflake DELETED_ON came after that date). A disable or remove counts only from the start of the login's current account (its first create after the last removal that comes before its latest create), and DELETED_ON only when no live user has that name, because Snowflake keeps dropped users in USERS and a name can be used again. An account that is gone from the platform with no recorded removal is NOT FOUND ON PLATFORM, and one that is disabled with no recorded date is ENDED, DATE NOT RECORDED. Both count as follow-up, never as clean.
  • Transfers. For each transfer dated in the period, every login the login map gives that person on each platform, and every role or group that login holds now. A held role counts when your map ties it to the old department and not to the new one. Who holds a role comes from the current views (GRANTS_TO_USERS with no DELETED_ON, the group members export, SVV_USER_GRANTS), not from replaying logs. Redshift groups are not read: SVV_USER_GRANTS lists roles. A Databricks group member is matched to a user by the member's value and the user's id; a member that is not a user (a nested group or a service principal) is counted and not expanded.
  • Account actions. Snowflake: the text of each successful CREATE USER, ALTER USER and DROP USER statement, and each GRANT ROLE ... TO USER and REVOKE ROLE ... FROM USER, read with comments (--, // and /* */), double quotes and string literals removed. A successful statement that starts as one of these but that the script cannot read fails an input check; it is never skipped. ALTER USER ... SET DISABLED = TRUE is a disable and = FALSE an enable. Snowflake does not list USER among its CREATE OR ALTER objects today (Snowflake: CREATE OR ALTER (read 2026-11-01)); if that form appears, the script reads it as it reads ALTER USER; a create ticket may match it, and it can start a login's current account. If the identifier is omitted, the statement modifies the active (i.e. logged in) user (Snowflake: ALTER USER (read 2026-11-01)), so an ALTER USER that names no user is read as a change to the user who ran it. ALTER USER ... ABORT ALL QUERIES changes no account and is not listed. A name in double quotes that holds a space or another character an unquoted name cannot fails an input check rather than being read as a different user. The actor is the statement's user_name. Databricks: the accounts actions in DATABRICKS_ACTION_KIND, with the actor from user_identity. An account-level row (workspace_id 0) is read as its kind. A workspace-level row, such as a user added to or removed from one workspace, is listed as a modify with the workspace id, and is never taken as the account's creation or end. Redshift: each SYS_USERLOG row. AWS describes the user_id column as the identifier of the user who submitted the unload, which is not an obvious fit for this view, so the script prints user_id N in the actor column instead of a name; check it against your own cluster before you report it as the actor. A failed Snowflake statement is counted and not listed.
  • Tickets. A ticket is matched to a recorded action on the same platform, login, kind of action and UTC day. A Databricks create ticket also matches a workspace-level add for a login with no account-level add in the records. A ticket with no match in the period is NOT RECORDED. Recorded actions with no ticket are counted, not flagged.

Step 4. Run it on the clean generated records

python3 ac2_account_evidence.py --demo writes generated exports (seed 232) in each platform's column names and reads them back. In this run every enabled account has signed in recently, every temporary account ended on time, the person who transferred holds only the new department's access, and every ticket matches a recorded action, so nothing needs follow-up. That says only that these generated records agree with these settings. The 17 input checks all passed (they are shown in step 6, where some fail on purpose).

17 input checks, 0 failed.

== 1. Inactive but enabled (AC-2(3)(d))
   period: state on 2026-10-05; filters: enabled accounts on snowflake, databricks, redshift; INACTIVE_DAYS=90; run at 2026-11-01 17:48:48 UTC; rows: 17; sha256: d048000b100d5da3038827b24c8797b537b486c5e0e9ef999591267466bc814c
   platform   | login                | basis_time          | basis        | days_idle | status
   databricks | ana@example.test     | 2026-10-02 11:31:00 | last sign-in | 3         | ok    
   databricks | ben@example.test     | 2026-10-02 12:19:00 | last sign-in | 3         | ok    
   databricks | chen@example.test    | 2026-10-02 13:34:00 | last sign-in | 3         | ok    
   databricks | dara@example.test    | 2026-10-02 10:55:00 | last sign-in | 3         | ok    
   databricks | sam@example.test     | 2026-10-02 09:10:00 | last sign-in | 3         | ok    
   databricks | svc-etl@example.test | 2026-10-05 08:09:00 | last sign-in | 0         | ok    
   redshift   | ana                  | 2026-10-02 15:36:00 | last sign-in | 3         | ok    
   redshift   | chen                 | 2026-10-02 08:20:00 | last sign-in | 3         | ok    
   redshift   | kim                  | 2026-10-02 10:55:00 | last sign-in | 3         | ok    
   redshift   | sam                  | 2026-10-02 16:48:00 | last sign-in | 3         | ok    
   redshift   | svc_bi               | 2026-10-05 08:01:00 | last sign-in | 0         | ok    
   snowflake  | admin_sam            | 2026-10-02 16:18:00 | last sign-in | 3         | ok    
   snowflake  | ana                  | 2026-10-02 12:20:00 | last sign-in | 3         | ok    
   snowflake  | ben                  | 2026-10-02 10:43:00 | last sign-in | 3         | ok    
   snowflake  | chen                 | 2026-10-02 11:39:00 | last sign-in | 3         | ok    
   snowflake  | dara                 | 2026-10-02 14:39:00 | last sign-in | 3         | ok    
   snowflake  | svc_load             | 2026-10-05 12:01:00 | last sign-in | 0         | ok    

== 2. Temporary and emergency accounts past their end (AC-2(2), AC-2(3)(a))
   period: end dates up to 2026-09-30; state on 2026-10-05; filters: your temp_accounts list plus Snowflake EXPIRES_AT; TEMP_GRACE_DAYS=1; run at 2026-11-01 17:48:48 UTC; rows: 4; sha256: d2392a8479d654921d3876b2bbd2bab68dbd2ead72b309f8e7f5fdb9bad235a6
   platform   | login                       | kind      | end_date   | source    | enabled_now     | last_use            | ended_on   | status       
   databricks | eli.contractor@example.test | temporary | 2026-09-15 | your list | false           | 2026-09-14 10:32:00 | 2026-09-15 | ended on time
   redshift   | eli_temp                    | temporary | 2026-09-15 | your list | not on platform | 2026-09-14 09:23:00 | 2026-09-16 | ended on time
   redshift   | fire_call                   | emergency | 2026-09-12 | your list | not on platform | 2026-09-11 08:40:00 | 2026-09-13 | ended on time
   snowflake  | eli_temp                    | temporary | 2026-09-20 | your list | false           | 2026-09-18 09:17:00 | 2026-09-20 | ended on time

== 3. Transfers that kept the old access (AC-2 h.2 and l)
   period: transfers dated 2026-09-01 to 2026-09-30; holdings on 2026-10-05; filters: roles and groups your department map ties to the old department; TRANSFER_DAYS=14; run at 2026-11-01 17:48:48 UTC; rows: 3; sha256: 6c9f6c2990a277c2e6dac8eb84984269fa378df0caa06ec8959c900fd6e33a7d
   employee          | transfer_date | platform   | logins            | old_access_held | status                   
   dara@example.test | 2026-09-10    | databricks | dara@example.test |                 | clean                    
   dara@example.test | 2026-09-10    | redshift   |                   |                 | no login on this platform
   dara@example.test | 2026-09-10    | snowflake  | dara              |                 | clean                    
   held roles and groups of transferred people that your department map does not list: 0

== 4a. Account actions the platforms recorded (AC-2(4))
   period: 2026-09-01 to 2026-09-30 (UTC); filters: every create, modify, enable, disable and remove; successful only; run at 2026-11-01 17:48:48 UTC; rows: 15; sha256: bd7ce2d7edd371e730232fce6accba0ac41f3425a8348ac8b7f4e622b92c5c78
   platform   | at_utc              | kind    | target                      | actor            | source                                 | ticket   
   databricks | 2026-09-01 10:00:00 | create  | eli.contractor@example.test | sam@example.test | audit add                              | no ticket
   databricks | 2026-09-01 10:00:00 | modify  | eli.contractor@example.test | sam@example.test | audit add (workspace 1234567890123456) | no ticket
   databricks | 2026-09-10 10:00:00 | modify  | dara@example.test           | sam@example.test | audit addPrincipalToGroup              | no ticket
   databricks | 2026-09-10 10:00:00 | modify  | dara@example.test           | sam@example.test | audit removePrincipalFromGroup         | no ticket
   databricks | 2026-09-15 10:00:00 | disable | eli.contractor@example.test | sam@example.test | audit deactivateUser                   | T-103    
   redshift   | 2026-09-01 10:00:00 | create  | eli_temp                    | user_id 100      | sys_userlog create                     | no ticket
   redshift   | 2026-09-02 10:00:00 | remove  | jon                         | user_id 100      | sys_userlog drop                       | T-102    
   redshift   | 2026-09-03 11:00:00 | modify  | ana                         | user_id 100      | sys_userlog alter                      | no ticket
   redshift   | 2026-09-08 10:00:00 | create  | kim                         | user_id 100      | sys_userlog create                     | T-101    
   redshift   | 2026-09-11 10:00:00 | create  | fire_call                   | user_id 100      | sys_userlog create                     | no ticket
   redshift   | 2026-09-13 10:00:00 | remove  | fire_call                   | user_id 100      | sys_userlog drop                       | T-106    
   redshift   | 2026-09-16 10:00:00 | remove  | eli_temp                    | user_id 100      | sys_userlog drop                       | T-107    
   snowflake  | 2026-09-01 10:00:00 | create  | eli_temp                    | admin_sam        | query q00361                           | T-104    
   snowflake  | 2026-09-10 10:00:00 | modify  | dara                        | admin_sam        | query q00362                           | T-105    
   snowflake  | 2026-09-10 10:00:00 | modify  | dara                        | admin_sam        | query q00365                           | T-105    
   counts: databricks create 1; databricks disable 1; databricks modify 3; redshift create 3; redshift modify 1; redshift remove 3; snowflake create 1; snowflake modify 2

== 4b. Change tickets with no recorded action (AC-2(4))
   period: 2026-09-01 to 2026-09-30 (UTC); filters: your change_tickets, matched on platform, login, action and day; run at 2026-11-01 17:48:48 UTC; rows: 0; sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
   recorded actions with no ticket: 7 of 15 (a count to look at, not a finding)

Needs follow-up: inactive 0, temporary 0, transfers 0, not recorded 0; input checks failed: 0. The script produces evidence; it does not say a control is met.

Step 5. Run it again with findings planted in the records

python3 ac2_account_evidence.py --demo --plant changes the generated records so that some things went wrong. An enabled Snowflake account has not signed in since 2026-05-14, a Databricks user was never deactivated and has no sign-in in the records, and a Redshift user has no sign-in either (list 1). The contractor's Snowflake and Databricks accounts were used after their end dates, and so was the emergency Redshift account. The Databricks one was removed from its workspace on 2026-09-15 but never deactivated, so the script lists that removal as a modify of the workspace, not as the account's end; another Redshift account was dropped on 2026-09-30, after an end date of 2026-09-15 (list 2). A person who moved from Finance to Data on 2026-09-10 still holds the Finance role on Snowflake and the Finance group on Databricks (list 3). Five tickets have no recorded action (list 4b). The script reports each one. The Snowflake EXPIRES_AT was left empty in this run, which is why that account shows as still enabled.

17 input checks, 0 failed.

== 1. Inactive but enabled (AC-2(3)(d))
   period: state on 2026-10-05; filters: enabled accounts on snowflake, databricks, redshift; INACTIVE_DAYS=90; run at 2026-11-01 17:48:49 UTC; rows: 23; sha256: 4a82cd1d22d0806e352830659a443c97ed9a86ec15a7f6a9413e2bf699de7939
   platform   | login                       | basis_time          | basis                     | days_idle | status              
   databricks | ana@example.test            | 2026-10-02 14:31:00 | last sign-in              | 3         | ok                  
   databricks | ben@example.test            | 2026-10-02 13:05:00 | last sign-in              | 3         | ok                  
   databricks | chen@example.test           | 2026-10-02 10:16:00 | last sign-in              | 3         | ok                  
   databricks | dara@example.test           | 2026-10-02 09:16:00 | last sign-in              | 3         | ok                  
   databricks | eli.contractor@example.test | 2026-09-22 11:24:00 | last sign-in              | 13        | ok                  
   databricks | ivy@example.test            |                     | no sign-in in the records |           | INACTIVE BUT ENABLED
   databricks | sam@example.test            | 2026-10-02 11:45:00 | last sign-in              | 3         | ok                  
   databricks | svc-etl@example.test        | 2026-10-05 11:04:00 | last sign-in              | 0         | ok                  
   redshift   | ana                         | 2026-10-02 11:22:00 | last sign-in              | 3         | ok                  
   redshift   | chen                        | 2026-10-02 12:59:00 | last sign-in              | 3         | ok                  
   redshift   | fire_call                   | 2026-09-18 15:17:00 | last sign-in              | 17        | ok                  
   redshift   | jon                         |                     | no sign-in in the records |           | INACTIVE BUT ENABLED
   redshift   | kim                         | 2026-10-02 16:02:00 | last sign-in              | 3         | ok                  
   redshift   | sam                         | 2026-10-02 08:29:00 | last sign-in              | 3         | ok                  
   redshift   | svc_bi                      | 2026-10-05 11:50:00 | last sign-in              | 0         | ok                  
   snowflake  | admin_sam                   | 2026-10-02 16:18:00 | last sign-in              | 3         | ok                  
   snowflake  | ana                         | 2026-10-02 12:20:00 | last sign-in              | 3         | ok                  
   snowflake  | ben                         | 2026-10-02 10:43:00 | last sign-in              | 3         | ok                  
   snowflake  | chen                        | 2026-10-02 11:39:00 | last sign-in              | 3         | ok                  
   snowflake  | dara                        | 2026-10-02 14:39:00 | last sign-in              | 3         | ok                  
   snowflake  | eli_temp                    | 2026-09-28 10:49:00 | last sign-in              | 7         | ok                  
   snowflake  | hal                         | 2026-05-14 08:00:00 | last sign-in              | 144       | INACTIVE BUT ENABLED
   snowflake  | svc_load                    | 2026-10-05 10:53:00 | last sign-in              | 0         | ok                  

== 2. Temporary and emergency accounts past their end (AC-2(2), AC-2(3)(a))
   period: end dates up to 2026-09-30; state on 2026-10-05; filters: your temp_accounts list plus Snowflake EXPIRES_AT; TEMP_GRACE_DAYS=1; run at 2026-11-01 17:48:49 UTC; rows: 4; sha256: 1f476bb91389c9f78007006a2f6cac4e40faa8b1f9d7ea3627913b0b16faf6c1
   platform   | login                       | kind      | end_date   | source    | enabled_now     | last_use            | ended_on   | status        
   databricks | eli.contractor@example.test | temporary | 2026-09-15 | your list | true            | 2026-09-22 11:24:00 |            | USED AFTER END
   redshift   | eli_temp                    | temporary | 2026-09-15 | your list | not on platform | 2026-09-14 14:46:00 | 2026-09-30 | ENDED LATE    
   redshift   | fire_call                   | emergency | 2026-09-12 | your list | true            | 2026-09-18 15:17:00 |            | USED AFTER END
   snowflake  | eli_temp                    | temporary | 2026-09-20 | your list | true            | 2026-09-28 10:49:00 |            | USED AFTER END

== 3. Transfers that kept the old access (AC-2 h.2 and l)
   period: transfers dated 2026-09-01 to 2026-09-30; holdings on 2026-10-05; filters: roles and groups your department map ties to the old department; TRANSFER_DAYS=14; run at 2026-11-01 17:48:49 UTC; rows: 3; sha256: 2b66125c4797dc17a23c83217957633511e74961d3bc16dd2ea5ec11280743da
   employee          | transfer_date | platform   | logins            | old_access_held | status                   
   dara@example.test | 2026-09-10    | databricks | dara@example.test | finance-readers | KEPT OLD ACCESS          
   dara@example.test | 2026-09-10    | redshift   |                   |                 | no login on this platform
   dara@example.test | 2026-09-10    | snowflake  | dara              | fin_analyst     | KEPT OLD ACCESS          
   held roles and groups of transferred people that your department map does not list: 0

== 4a. Account actions the platforms recorded (AC-2(4))
   period: 2026-09-01 to 2026-09-30 (UTC); filters: every create, modify, enable, disable and remove; successful only; run at 2026-11-01 17:48:49 UTC; rows: 11; sha256: 75a87feba37a65fe2561df234b08dcc1e178822e80f91766e870a7cd88ca7ced
   platform   | at_utc              | kind   | target                      | actor            | source                                    | ticket   
   databricks | 2026-09-01 10:00:00 | create | eli.contractor@example.test | sam@example.test | audit add                                 | no ticket
   databricks | 2026-09-01 10:00:00 | modify | eli.contractor@example.test | sam@example.test | audit add (workspace 1234567890123456)    | no ticket
   databricks | 2026-09-10 10:00:00 | modify | dara@example.test           | sam@example.test | audit addPrincipalToGroup                 | no ticket
   databricks | 2026-09-15 10:00:00 | modify | eli.contractor@example.test | sam@example.test | audit delete (workspace 1234567890123456) | no ticket
   redshift   | 2026-09-01 10:00:00 | create | eli_temp                    | user_id 100      | sys_userlog create                        | no ticket
   redshift   | 2026-09-03 11:00:00 | modify | ana                         | user_id 100      | sys_userlog alter                         | no ticket
   redshift   | 2026-09-08 10:00:00 | create | kim                         | user_id 100      | sys_userlog create                        | T-101    
   redshift   | 2026-09-11 10:00:00 | create | fire_call                   | user_id 100      | sys_userlog create                        | no ticket
   redshift   | 2026-09-30 10:00:00 | remove | eli_temp                    | user_id 100      | sys_userlog drop                          | no ticket
   snowflake  | 2026-09-01 10:00:00 | create | eli_temp                    | admin_sam        | query q00364                              | T-104    
   snowflake  | 2026-09-10 10:00:00 | modify | dara                        | admin_sam        | query q00365                              | T-105    
   counts: databricks create 1; databricks modify 3; redshift create 3; redshift modify 1; redshift remove 1; snowflake create 1; snowflake modify 1

== 4b. Change tickets with no recorded action (AC-2(4))
   period: 2026-09-01 to 2026-09-30 (UTC); filters: your change_tickets, matched on platform, login, action and day; run at 2026-11-01 17:48:49 UTC; rows: 5; sha256: 469e8c35302158eb351f8716cb8ee1a5c844efb21069856cb79111a3cb264988
   ticket | platform   | login                       | action  | change_date | status      
   T-102  | redshift   | jon                         | remove  | 2026-09-02  | NOT RECORDED
   T-103  | databricks | eli.contractor@example.test | disable | 2026-09-15  | NOT RECORDED
   T-106  | redshift   | fire_call                   | remove  | 2026-09-13  | NOT RECORDED
   T-107  | redshift   | eli_temp                    | remove  | 2026-09-16  | NOT RECORDED
   T-108  | redshift   | lee                         | create  | 2026-09-20  | NOT RECORDED
   recorded actions with no ticket: 8 of 11 (a count to look at, not a finding)

Needs follow-up: inactive 3, temporary 4, transfers 2, not recorded 5; input checks failed: 0. The script produces evidence; it does not say a control is met.

Step 6. Run it with faults planted in the inputs

A list is only as good as its inputs, so the script checks them first and marks every list INCOMPLETE if one fails. python3 ac2_account_evidence.py --demo --plant-input plants one fault for 14 of the 17 checks, and each one fails and names what it found. Three checks do not fail in this run: the check on the dates and platforms in the settings (it would stop the script before any list), and the Snowflake and Databricks day-by-day record checks, which use the same query as the Redshift one that does fail.

Input checks
  [ok] dates and platforms in the settings
  [FAIL] every time period is set (AC-2 leaves each one to you): not set to a whole number of days: TRANSFER_DAYS
  [FAIL] every file is there with every column the script reads: redshift_svv_user_grants.csv has no column role_name
      read 9 rows into snowflake_users
      read 7 rows into snowflake_grants_to_users
      read 366 rows into snowflake_query_history
      read 8 rows into databricks_users
      read 5 rows into databricks_group_members
      read 357 rows into databricks_audit
      read 5 rows into redshift_svv_user_info
      read 0 rows into redshift_svv_user_grants
      read 8 rows into redshift_sys_userlog
      read 259 rows into redshift_sys_connection_log
      read 10 rows into hr_people
      read 2 rows into hr_transfers
      read 22 rows into login_map
      read 4 rows into temp_accounts
      read 10 rows into role_departments
      read 8 rows into change_tickets
  [FAIL] every date and time is written as YYYY-MM-DD [HH:MM:SS] (UTC): not readable, so left out: snowflake_users.created_on: 1, temp_accounts.end_date: 1
  [FAIL] every value is one the script knows: snowflake_users.disabled not true or false: 1; role_departments.platform not one of snowflake, databricks, redshift: 1; temp_accounts.kind not temporary or emergency: 1; change_tickets.action not one of create, modify, enable, disable, remove: 1
  [FAIL] every successful CREATE, ALTER and DROP USER statement and role grant to a user could be read: 1 successful statement(s) look like one but name no user the script can read
  [FAIL] every Databricks action that looks like user management is one the script lists: unknown: updateUserRole
  [FAIL] every Databricks action that looks like a sign-in is on the sign-in list: unknown: passkeyLogin
  [FAIL] every Databricks sign-in and account action has a readable response status and workspace_id: 1 row(s) whose response is not JSON with a status code
  [FAIL] every SYS_USERLOG action is alter, create, drop or rename: unknown: suspend
  [FAIL] no login appears twice in one platform's account list: snowflake/ana x2
  [FAIL] no login is mapped to two people: snowflake/ana
  [FAIL] every role or group in the department map is held by someone: matches no held role: redshift/data_rw, redshift/fin_ro, snowflake/ghost_role
  [FAIL] every transfer names someone in the HR people list: not in hr_people: zed@example.test
      Databricks group members that are not account users (groups or service principals; not expanded): 0
  [ok] snowflake: records exist for every day from 2026-07-07 to 2026-10-05
  [ok] databricks: records exist for every day from 2026-07-07 to 2026-10-05
  [FAIL] redshift: records exist for every day from 2026-07-07 to 2026-10-05: 1 day(s) with none, first 2026-09-14, last 2026-09-14

17 input checks, 14 failed.  ** INCOMPLETE: 14 input check(s) failed; read the lists with that in mind **
Which planted fault trips which check
CheckPlanted fault
every time period is setTRANSFER_DAYS left unset (list 3 prints NOT RUN)
every file is there with every column the script readsthe Redshift SVV_USER_GRANTS export with role_name renamed to role
every date and time is written as YYYY-MM-DD [HH:MM:SS] (UTC)a Snowflake created_on of 2026-13-45, and a temporary account end date written 9/20/26
every value is one the script knowsa DISABLED value of maybe, a platform called oracle, a kind called tmp, a ticket action called rename
every successful CREATE, ALTER and DROP USER statement and role grant to a user could be reada successful statement that is just ALTER USER
every Databricks action that looks like user management is one the script listsan action called updateUserRole
every Databricks action that looks like a sign-in is on the sign-in listan action called passkeyLogin (a name we invented for the test)
every Databricks sign-in and account action has a readable response status and workspace_ida login whose response is not JSON
every SYS_USERLOG action is alter, create, drop or renamean action called suspend
no login appears twice in one platform's account listthe same Snowflake user twice
no login is mapped to two peopleone Snowflake login mapped to two emails
every role or group in the department map is held by someonea role called GHOST_ROLE that nobody holds (the renamed Redshift column also leaves two Redshift roles unmatched)
every transfer names someone in the HR people lista transfer for an email that is not in hr_people
redshift: records exist for every dayevery Redshift connection record of 2026-09-14 removed

How to read the results

  • Input checks first. If any check failed, the lists say INCOMPLETE. A list built on records with a missing day or an unreadable value is not a population you can hand over.
  • Lists 1 to 3 are findings to look at. A flagged row is a question for the account owner, not a verdict. An account can be inactive for a reason you can document; the script cannot know. Snowflake creates an internal WORKSHEETS_APP_USER user the first time Snowsight is used (Snowflake: ACCOUNT_USAGE.USERS (read 2026-11-01)), and it may show in list 1.
  • List 4a is a population, not a finding. It is every account action the script reads in the period (successful ones only), sorted and fingerprinted. If you keep the row count and SHA-256 somewhere you cannot edit, you can check later that a list matches the one you produced.
  • List 4b is where records and tickets disagree. NOT RECORDED means no action with that login, kind and day appears in the platform's records. The change may have happened on another day, under another login, or not at all, or the platform's records no longer reach back that far.
  • Not checked is not clean. A platform left out of PLATFORMS_EXPORTED is printed as not checked, and a list whose setting is blank prints NOT RUN and writes a CSV that says so.

Run it on your own data

To run it on your own data, open the download and change only the settings between the lines marked CHANGE THESE FOR YOUR OWN DATA: set EXPORT_DIR to the folder that holds your exports, PERIOD_START, PERIOD_END and AS_OF to your dates, PLATFORMS_EXPORTED to the platforms you export, and INACTIVE_DAYS, TEMP_GRACE_DAYS and TRANSFER_DAYS to the number of days your own policy sets. Save each export under the file name the table gives, with the platform's own column names. Change nothing else: names are matched without regard to case, and a blank value is read as missing. Then run it without --demo.

We tested those steps on a second, separate set of generated exports: other logins in mixed case, upper-case Snowflake headers, fractional seconds, a blank department, a blank email in the login map, a CREATE USER statement with a comment and quotes, and no Databricks files at all, with Databricks left out of PLATFORMS_EXPORTED (you do not need the files for a platform you leave out). The script listed Databricks as not checked, ran 16 input checks with none failing, and reported one inactive account, two temporary accounts to follow up (one used after its end date, and one whose Snowflake EXPIRES_AT came after the end date on the list), a third found only through its EXPIRES_AT that ended on time, one transfer that kept the old access despite a difference in case, and one ticket with no recorded action. That test is also generated data.

Download ac2_account_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 (232). The HR lists, the login map, the temporary account list, the department map and the tickets are generated too. The people, roles and counts are invented.
  • Period: 2026-09-01 to 2026-09-30, UTC, inclusive. Records are read as of the end of the export day, 2026-10-05, and must cover every day from INACTIVE_DAYS before it (90 in the demo), which is why the day-by-day checks start on 2026-07-07.
  • Fingerprints: each list is sorted on every column before it is hashed, so the same input gives the same SHA-256 on every run.
  • Our assumptions, to check against one of your own rows: a Databricks sign-in is one of the actions on DATABRICKS_SIGNIN_ACTIONS, taken from the authentication events on the AWS and Azure audit log reference pages; a Databricks group member's value is the member's id (the groups page names the field but does not say what it holds); a successful Databricks row has the status 200 in response; a Redshift sign-in is the event authenticated in SYS_CONNECTION_LOG (the page's sample shows that value, but does not list every value); Snowflake and Databricks names are lower-cased before they are compared; and the platforms' column names are as the linked pages list them on 2026-11-01.

Limits

  • This is not a client result. Every record here is generated. We have not run the script on a real Snowflake, Databricks or Redshift account.
  • NIST sets the questions, not the answers. Every bracketed time period is yours, and so is the choice of which enhancements apply. The script cannot tell you whether your numbers are the right ones, and the assessor decides what evidence is enough.
  • "Automatically" cannot be seen. AC-2(2), AC-2(3) and AC-2(4) say the action is automatic. The script shows that an account ended, and when, and that actions were recorded. It cannot show that a machine did it. Snowflake's EXPIRES_AT is the only field we read that ends an account by itself.
  • The records may not hold what you need. Redshift's SYS_USERLOG says what changed to which user and not, as far as the page tells us, who did it. SVV_USER_INFO lists no disabled state, so the script can see a Redshift account end only when it is dropped. Snowflake's QUERY_HISTORY keeps 365 days, and statements longer than 100K characters are truncated. Snowflake records SCIM provisioning requests in REST_EVENT_HISTORY (Snowflake: REST_EVENT_HISTORY (read 2026-11-01)), which the script does not read. Databricks keeps its audit table for 365 days, and its workspace-level events stay in each workspace's region: an export from one region misses the others, and the script cannot tell. Redshift's system log views keep about seven days unless you keep copies or turn on audit logging. Databricks group members must be collected group by group, and nested groups are not expanded.
  • Read, not enforced. The script reads exports. It never connects to a platform and never changes an account.
  • Parsing has edges. A Snowflake statement is read from its text. One that starts as a user change but names the user in a form the script does not read fails an input check when it is successful; it is never skipped. A user change written inside a procedure, a task or EXECUTE IMMEDIATE is not read from that text itself. Shared and service accounts have no HR record, so they show only in lists 1, 2 and 4.
  • Evidence, not a verdict. The script produces evidence. It never says a control is met, and it does not know what any assessor will accept.

Tested on

Platform
DuckDB (the script reads exported CSV files; it never connects to Snowflake, Databricks or Redshift)
Edition
duckdb 1.5.6 and 2.0.0; Python 3
Date
2026-11-01
Dataset
Generated records (seed 232) in each platform's published column names; not a company's data

When this is not enough

  • Accounts outside the three platforms: an identity provider, a BI tool or a cloud console. The four lists see only what the three platforms record.
  • Access that is not an account role: object privileges granted straight to a user, and Redshift groups. List 3 reads roles and groups only.
  • A policy that is silent. If your policy has no inactivity period, the script has nothing to measure against, and choosing one is for you and your assessor.
  • A single run. One export shows one day. Run it on a schedule and keep every run's lists and hashes.
  • Platforms other than these three, and the other controls in the AC family.

The script is free. Seeing every access change each day is a different job.

The script in this guide is free to run on your own exports. Data Control Tower deploys into your own Azure subscription from a template your IT team can read before it runs. Data Control Tower lists every access change and who made it, including access someone gave by hand.

See Data Control Tower