Check recipe · Access review evidence

PCI DSS 7.2.4 access review: Snowflake, Databricks, Redshift

DuckDB2026-11-01Script and PDF downloadable

The short answer

The PCI DSS 7.2.4 review needs a record that user accounts and their access were reviewed at least every six months, wrong access was dealt with, and management confirmed what remains. Our free script reads Snowflake, Databricks and Redshift exports and prints four lists: the review packet, timing, coverage and decisions carried out. That is evidence; the assessor decides.

What 7.2.4 asks for, in our words

PCI DSS v4.0.1 (June 2024) is the Payment Card Industry Data Security Standard. Its text is licensed by the PCI Security Standards Council, so this page names requirement 7.2.4 by its id and describes it in our own words; it does not quote it. Get the standard from the PCI Security Standards Council: document library (read 2026-11-01) and read 7.2.4, its testing procedures and the notes around it yourself. What the text supports:

  • Who and how often. Every user account, and the access tied to it, third-party and vendor accounts included, is reviewed at least once every six months.
  • What the review decides. Whether the access still fits the person's job. Access found to be wrong is dealt with, and management confirms the access that remains is still appropriate.
  • What an assessor reads. The testing procedures have the assessor read the review policy and the documented results of past reviews, and talk to the people who run them. So the evidence is the review's own record: what was reviewed, when, by whom, and what came of it.
  • Not 7.2.4. Application and system accounts fall under 7.2.5 and 7.2.5.1, and under 8.6.1 to 8.6.3. The script lists them apart so none drops out of sight.
  • What "every six months" means. Section 7, Table 4 of the standard defines six months as a gap of no more than 180 to 184 days, or the same day of the month six months later. The script's REVIEW_INTERVAL_DAYS is 184, the longest gap that table allows. The same section says a late activity is not automatically a failure where a documented process caught and handled it. It also says that in a first assessment against the requirement, a review in the most recent six-month period is enough, provided you also have documented policies and procedures for keeping to the schedule; the assessor checks both. The assessor decides about a late review; the script only shows the dates.
  • What the standard leaves to you. It sets no deadline for addressing wrong access. So REMEDIATION_DAYS has no default: until you set it, list 4 does not run.
  • Since when. The requirement became mandatory after 31 March 2025.

A review is something people do. A data platform keeps the lists they review and the access they change. This page shows how to read those lists, and how to check the review's own record against them. It does not run the review, and it does not say any review was enough. Neighbors 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-ins are in the SOC 2 user access guide; inactive, temporary and transferred accounts are in the NIST AC-2 guide. This one is about the review itself.

What you will have at the end

A Python script, pci_access_review_evidence.py, that reads CSV exports of each platform's account, grant and audit records, with column names taken from the platforms' documentation (Databricks names written in snake_case), plus lists you keep and the review's own record. It runs 18 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
ListWhat the review needsWhat it flags
1. The review packetWhat the reviewer is given to reviewEvery user account on each platform with each role or group it holds, direct and inherited, its owner, department and job function. NO OWNER flags an account that matches no employee and no third party. Each platform's packet gets a SHA-256, which the review records. List 1b shows system accounts apart (7.2.5 and 7.2.5.1).
2. Review timingAt least every six months, acknowledged for managementEach completed review per platform, with the days since the one before. GAP OVER 184 DAYS, OVERDUE (the last review is more than 184 days before the export), NO REVIEW RECORDED, NO ACKNOWLEDGMENT.
3. Review coverageThe review covered everything it was givenThe latest review against the packet it was given. NOT REVIEWED (a packet row with no decision), REVIEWED OWN ACCESS (the reviewer is the account's owner), NO REVIEWER.
4. Decisions carried outWrong access is dealt withEvery remove decision, whether the access is still held now, and when it went where the platform records that. STILL HELD PAST DEADLINE and REMOVED LATE are the findings.

The records we ran it on are generated: fixed rows, 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 requirement is met or that anyone will accept it.

What you will need

  • Snowflake: ACCOUNT_USAGE.USERS has NAME, TYPE, DISABLED, CREATED_ON, DELETED_ON and LAST_SUCCESS_LOGIN (Snowflake: ACCOUNT_USAGE.USERS (read 2026-11-01)), and GRANTS_TO_USERS has ROLE, GRANTEE_NAME and DELETED_ON, which is NULL while the grant is active (Snowflake: ACCOUNT_USAGE.GRANTS_TO_USERS (read 2026-11-01)). Both can lag by up to 120 minutes. The user types, including the service types the script lists apart, are described on Snowflake: user management (read 2026-11-01): SERVICE, SERVICE_AGENT, LEGACY_SERVICE and SNOWFLAKE_SERVICE, and an empty type works like PERSON. The views are in the SNOWFLAKE database, whose schemas an ACCOUNTADMIN must grant you access to, and their history is kept for 365 days (Snowflake: Account Usage (read 2026-11-01)).
  • Snowflake role inheritance: GRANTS_TO_ROLES rows with GRANTED_ON = ROLE (Snowflake: ACCOUNT_USAGE.GRANTS_TO_ROLES (read 2026-11-01)). The page says NAME is the object the privilege is granted on, here the role granted, and GRANTEE_NAME the recipient role. Only a USAGE row is inheritance: Snowflake says a role's owner, the role with the OWNERSHIP privilege on it, does not inherit the owned role's privileges (Snowflake: overview of access control (read 2026-11-01)), so the script reads PRIVILEGE and skips OWNERSHIP rows. Check one row of your own before you rely on the inherited rows.
  • Databricks: account users with id, userName and active (Databricks: list account users (read 2026-11-01)); service principals with id, applicationId, displayName and active (Databricks: list account service principals (read 2026-11-01)); and each group with its id, displayName and members, which the list call no longer returns, so you get them one group at a time (Databricks: get account group (read 2026-11-01)). The page does not say whether a member can be a group; the script treats a member whose value is another group's id as a nested group and counts members it cannot place. For dates it reads system.access.audit (Databricks: audit log system table (read 2026-11-01)): account-level events carry workspace_id 0. The actions removePrincipalFromGroup, removePrincipalsFromGroup and delete are the ones the script reads; all three are on the audit log reference (Databricks: audit log reference (read 2026-11-01); Azure Databricks: audit log reference (read 2026-11-01)), where removePrincipalsFromGroup is how SCIM provisioning records removals. Databricks keeps the table for 365 days (Databricks: system tables overview (read 2026-11-01)), and most audit logs are only available in the region of the workspace (Databricks: audit log system table (read 2026-11-01)).
  • Redshift: SVV_USER_INFO (user_name, user_id, superuser; all rows only for superusers) (Amazon Redshift: SVV_USER_INFO (read 2026-11-01)); SVV_USER_GRANTS (roles explicitly granted to users) (Amazon Redshift: SVV_USER_GRANTS (read 2026-11-01)) and SVV_ROLE_GRANTS (roles explicitly granted to roles) (Amazon Redshift: SVV_ROLE_GRANTS (read 2026-11-01)), both visible in full to superusers and users with the ACCESS SYSTEM TABLE permission; and SYS_USERLOG, visible only to superusers (Amazon Redshift: SYS_USERLOG (read 2026-11-01)). Its page gives no retention period. AWS's reference says STL system views retain seven days of log history (Amazon Redshift: system tables and views reference (read 2026-11-01)), so plan for about seven days of SYS_USERLOG unless you keep copies. These views list grants, not who owns what, and a role revoke has no date in them.
  • Your own lists: HR people (with department and job function), third-party accounts (vendor and sponsor), a login map, the system accounts you name, and the review record: one row per review and one row per decision. 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 views into one folder

The script reads these seventeen files from EXPORT_DIR, plus the review packets in a folder named packets inside it. 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. Dates are read as YYYY-MM-DD, timestamps 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 seventeen files
FileColumns the script readsDefined in
snowflake_users.csvname, type, disabled, created_on, deleted_on, last_success_loginSnowflake: ACCOUNT_USAGE.USERS (read 2026-11-01)
snowflake_grants_to_users.csvcreated_on, deleted_on, role, grantee_nameSnowflake: ACCOUNT_USAGE.GRANTS_TO_USERS (read 2026-11-01)
snowflake_grants_to_roles.csvcreated_on, deleted_on, privilege, granted_on, name, grantee_nameSnowflake: ACCOUNT_USAGE.GRANTS_TO_ROLES (read 2026-11-01)
databricks_users.csvid, username, active (the SCIM id, userName and active)Databricks: list account users (read 2026-11-01)
databricks_service_principals.csvid, application_id, display_name, activeDatabricks: list account service principals (read 2026-11-01)
databricks_group_members.csvgroup_id, group_name, member_id (one row per member of each group: its id and displayName, and the member's value)Databricks: get account group (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_id, superuserAmazon 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_svv_role_grants.csvrole_name, granted_role_name (role_name includes granted_role_name)Amazon Redshift: SVV_ROLE_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)
hr_people.csvemployee_email, department, job_functionYou keep it: your HR file
login_map.csvplatform, login, employee_emailYou keep it: which login belongs to which person
third_party_accounts.csvplatform, login, vendor, sponsor_emailYou keep it: vendor and contractor accounts and who sponsors each
system_accounts.csvplatform, loginYou keep it: the application and system accounts you name (7.2.5 and 7.2.5.1)
reviews.csvreview_id, platform, packet_sha256, completed_date, acknowledged_by, acknowledged_dateYou keep it: one row per review per platform
review_decisions.csvreview_id, platform, login, access, decision (keep or remove), reviewer_email, decision_dateYou keep it: one row per decision

Databricks keeps workspace-level events in each workspace's region; the script reads only account-level rows (workspace_id 0) for dates, because only those say an account or a group membership changed. A workspace-level row is a change to that workspace and does not count as removal.

Run python3 pci_access_review_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 as a role that can read SNOWFLAKE.ACCOUNT_USAGE; set the session to UTC first)
ALTER SESSION SET TIMEZONE = 'UTC';
SELECT name, type, disabled, created_on, deleted_on, last_success_login FROM SNOWFLAKE.ACCOUNT_USAGE.USERS;
SELECT created_on, deleted_on, role, grantee_name FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_USERS;
-- every grant on a role; the script counts only USAGE as inheritance (an OWNERSHIP row is not)
SELECT created_on, deleted_on, privilege, granted_on, name, grantee_name FROM SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES
WHERE granted_on = 'ROLE';

-- Databricks: system.access.audit (maps and structs written as JSON text). Databricks keeps 365 days: take all of
-- them. Workspace-level rows are kept in the workspace's region: run it in each region you use.
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(), 365);
-- databricks_users.csv (id, username, active): GET /api/2.0/accounts/{account_id}/scim/v2/Users
--   -> id, userName, active, one row per user
-- databricks_service_principals.csv (id, application_id, display_name, active):
--   GET .../scim/v2/ServicePrincipals -> id, applicationId, displayName, active
-- databricks_group_members.csv (group_id, group_name, member_id): GET .../scim/v2/Groups, then
--   GET .../scim/v2/Groups/{id} for each group (the list no longer returns members) -> id, 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_name, user_id, superuser FROM svv_user_info;
SELECT user_name, role_name FROM svv_user_grants;
SELECT role_name, granted_role_name FROM svv_role_grants;
SELECT user_id, user_name, original_user_name, action, record_time FROM sys_userlog;

A review needs its packet. Each run writes its lists and each platform's packet to its own folder, evidence-out/run-<UTC time>/, with the packet at packets/packet_<platform>_<sha256>.csv, prints the SHA-256, and deletes nothing from earlier runs. Give the reviewer that packet, record the SHA-256 on the review's row in reviews.csv, and keep the packet file: put it in the packets folder next to your exports. A packet file whose rows no longer hash to its name fails an input check. Only each platform's latest review needs its packet; an older review, one from before you used the script included, is read for its dates and decisions.

Step 2. Set your settings

PCI sets the six-month rhythm and leaves the deadline for addressing wrong access to you, so the script has a default for the first and none for the second. Here is the settings block at the top of the download. The demo fills in 14 for REMEDIATION_DAYS only so there is something to measure; that is not a recommendation, and nothing on this page says what your number should be. REVIEW_INTERVAL_DAYS is 184, the longest gap Table 4 allows; lower it if your policy is stricter.

# ---- CHANGE THESE FOR YOUR OWN DATA ---------------------------------------------------------
AS_OF = "2026-10-30"                 # the day you took the exports (UTC)
EXPORT_DIR = None                    # folder holding the export files named in FILES below
PLATFORMS_EXPORTED = ["snowflake", "databricks", "redshift"]   # a platform not listed is NOT CHECKED
REVIEW_INTERVAL_DAYS = 184           # longest gap between reviews you will accept (PCI DSS v4.0.1 section 7, Table 4)
REMEDIATION_DAYS = None              # no default: days after a remove decision by which the access must be gone
# ----------------------------------------------------------------------------------------------

Step 3. How each list is decided

  • Accounts. Snowflake: every user in USERS with no DELETED_ON. Databricks: every SCIM user, and every service principal as a system account. Redshift: every user in SVV_USER_INFO. An account is a system account if its Snowflake type is one of the service types, if it is a Databricks service principal, or if you name it in system_accounts.csv. Every other account is in the packet. List 1b labels the system accounts 7.2.5 and 7.2.5.1; the standard's notes also point to 8.6.1 to 8.6.3 for them.
  • Access. Roles and groups the account holds now: Snowflake GRANTS_TO_USERS with no DELETED_ON; Databricks group members, joined on the member's value and the user's id; Redshift SVV_USER_GRANTS. Inherited access follows role-in-role grants (Snowflake GRANTS_TO_ROLES rows with GRANTED_ON ROLE and PRIVILEGE USAGE, since an OWNERSHIP row is not inheritance; Redshift SVV_ROLE_GRANTS) and Databricks nested groups, as far as they go; each inherited row says via the role the account holds. A Snowflake grant created before the live user with that name was created belonged to an earlier holder of the name: it is left out and counted. A Redshift superuser gets a row, (superuser), because that access is a flag and not a role. An account with nothing the script reads gets a row, (no role or group read), so it cannot drop out of the review. Not read, so not in the packet: object privileges granted straight to a user, Snowflake database roles, Redshift user groups, the Databricks account admin role and workspace-local groups (each workspace's admins group included), and a Redshift superuser's reach beyond the flag. An account shown as (no role or group read) may still hold access through one of them.
  • Owner. The login map gives each login an employee, who must be in the HR list; otherwise the login is looked up in the third-party list. An account that matches neither is NO OWNER. Last sign-in is shown for Snowflake only; the other two exports here carry none.
  • The packet's SHA-256. Each platform's packet is its platform, login, access and held_how, sorted and hashed. It is what a review records, and the review's packet file must hash back to it.
  • Timing. Completed reviews per platform, in date order. A review is a GAP OVER 184 DAYS if more than REVIEW_INTERVAL_DAYS days passed since the one before it, and NO ACKNOWLEDGMENT if nobody is named, or no date given, for management's confirmation. After the last review the clock keeps running to the export day: more than REVIEW_INTERVAL_DAYS days is OVERDUE.
  • Coverage. Only the latest review per platform, against the packet file it names. Each packet row needs one decision (keep or remove). No decision is NOT REVIEWED; a decision with no reviewer is NO REVIEWER; a reviewer whose email is the one the login map gives for that login is REVIEWED OWN ACCESS. The same person may review many logins; the script only flags the one reviewing themselves. Access added since that packet is counted, not flagged: it goes into the next review.
  • Decisions carried out. Every remove decision in any review. Held now means the person still holds that access in today's packet: through the login itself; through its new name after a Redshift rename in SYS_USERLOG made since the review's packet could have been taken, that is since the platform's previous review, or REVIEW_INTERVAL_DAYS before this one if there was none (along every rename in a row, and a drop under the new name dates the removal), unless the new name is already its own row in the packet the review was given, or the review decided on that login, since then it is a different user; or through another user login the login map gives the same person, not a system account, unless a review on or after that one decided to keep that login's access. The held_now_by column names the logins that hold it, and account_enabled is true if any of them is enabled. When nothing holds it, account_enabled is the decided login's state, renames followed. If it does and a later review decided to keep it, the row says KEPT BY A LATER REVIEW. Otherwise it is STILL HELD PAST DEADLINE once the decision date plus REMEDIATION_DAYS has passed, and STILL HELD, NOT YET DUE before that. If it is gone, the date is the first removal the platform recorded on or after the decision: Snowflake grant DELETED_ON or user DELETED_ON; Databricks account-level removePrincipalFromGroup, removePrincipalsFromGroup or delete with status 200 (a refused attempt is not a removal); Redshift drop of the user in SYS_USERLOG. A removal after the deadline is REMOVED LATE. Gone with no recorded date, such as a Redshift role revoke, is GONE, DATE NOT RECORDED, or GONE, BEFORE THE RECORDS START when the decision is older than the earliest Databricks audit row or Redshift user log row in your export. Databricks keeps 365 days, so a Databricks decision inside that window needs the records to reach it, and an input check says so if they do not. A deactivated Databricks account that still holds the group is STILL HELD, with account_enabled false: whether deactivation was your way of carrying out the decision is for you and the assessor.

Step 4. Run it on the clean generated records

python3 pci_access_review_evidence.py --demo writes generated exports in each platform's column names, with a review record, and reads them back. In this run every account has an owner, both reviews are within 184 days, every packet row has a decision by someone other than the account's owner, and all five remove decisions are carried out within the 14 days the demo sets. So nothing needs follow-up. That says only that these generated records agree with these settings. The generated Redshift log reaches back further than the seven days to plan for; on a real account, a drop older than your log keeps shows as GONE, DATE NOT RECORDED. (Lists 1 and 1b are left out of the excerpts below; the script prints them first.)

Input checks
  [ok] dates, interval and platforms in the settings
  [ok] REMEDIATION_DAYS is set (PCI sets no deadline for addressing wrong access: it is yours)
  [ok] every file is there with every column the script reads
      read 8 rows into snowflake_users
      read 9 rows into snowflake_grants_to_users
      read 4 rows into snowflake_grants_to_roles
      read 6 rows into databricks_users
      read 1 rows into databricks_service_principals
      read 6 rows into databricks_group_members
      read 5 rows into databricks_audit
      read 5 rows into redshift_svv_user_info
      read 3 rows into redshift_svv_user_grants
      read 1 rows into redshift_svv_role_grants
      read 4 rows into redshift_sys_userlog
      read 10 rows into hr_people
      read 17 rows into login_map
      read 1 rows into third_party_accounts
      read 2 rows into system_accounts
      read 6 rows into reviews
      read 23 rows into review_decisions
  [ok] every date and time is written as YYYY-MM-DD [HH:MM:SS] (UTC)
  [ok] every value is one the script knows
  [ok] every Databricks removal row has a readable response status and workspace_id
  [ok] no login appears twice in one platform's account list
  [ok] no login is mapped to two people
  [ok] no person appears twice in the HR list, and no login twice in the third-party list
  [ok] every login in system_accounts is an account on its platform
  [ok] every login in third_party_accounts is an account on its platform
  [ok] every login that holds a role or group is in its platform's account list
      Databricks group members that are no user, service principal or group in the export: 0
      Snowflake grants older than the user who now has that name (an earlier holder's; left out): 0
  [ok] each review appears once per platform and none is dated after the export
  [ok] each platform's latest review has its packet file, and it still hashes to the recorded SHA-256
  [ok] no access has two decisions in one review
  [ok] every decision belongs to a review in the review list
  [ok] every decision is about a row of the packet its review was given
  [ok] Databricks account records reach back to every remove decision of the last 365 days

18 input checks, 0 failed.
== 2. Review timing (7.2.4: at least every six months)
   period: reviews completed up to 2026-10-30; filters: REVIEW_INTERVAL_DAYS=184; run at 2026-11-01 19:57:52 UTC; rows: 9; sha256: 33eb46c4e6acd840b9ebd3cfac58d53e61e469edae91fd718ff76906bbebf82a
   platform   | review_id               | completed  | days_since_previous | packet_sha256 | acknowledged_by  | acknowledged_on | status
   snowflake  | R-2026-04               | 2026-04-14 |                     |               | lee@example.test | 2026-04-16      | ok    
   snowflake  | R-2026-09               | 2026-09-28 | 167                 | 6a52b4af9d20  | lee@example.test | 2026-09-30      | ok    
   snowflake  | (since the last review) | 2026-09-28 | 32                  |               |                  |                 | ok    
   databricks | R-2026-04               | 2026-04-14 |                     |               | lee@example.test | 2026-04-16      | ok    
   databricks | R-2026-09               | 2026-09-28 | 167                 | 733f41979e27  | lee@example.test | 2026-09-30      | ok    
   databricks | (since the last review) | 2026-09-28 | 32                  |               |                  |                 | ok    
   redshift   | R-2026-04               | 2026-04-14 |                     |               | lee@example.test | 2026-04-16      | ok    
   redshift   | R-2026-09               | 2026-09-28 | 167                 | df9f4d68b986  | lee@example.test | 2026-09-30      | ok    
   redshift   | (since the last review) | 2026-09-28 | 32                  |               |                  |                 | ok    

== 3. Review coverage (the latest review against the packet it was given)
   period: the latest review per platform up to 2026-10-30; filters: packet matched by its SHA-256; one decision per packet row; run at 2026-11-01 19:57:52 UTC; rows: 23; sha256: b37bf27438f4f8afa73e9c3c1f6bf9372157b4db3144ca65a2a3c995f955532a
   showing the 0 row(s) that need a look; all 23 are in 3_review_coverage.csv
   snowflake: 8 packet rows, 0 not reviewed, 0 reviewed by the account holder, 0 with no reviewer
   databricks: 9 packet rows, 0 not reviewed, 0 reviewed by the account holder, 0 with no reviewer
   redshift: 6 packet rows, 0 not reviewed, 0 reviewed by the account holder, 0 with no reviewer
   access rows in today's packet that were not in the latest review's packet (they go into the next review; a count to look at, not a finding): databricks 1; redshift 2; snowflake 1

== 4. Decisions carried out (every remove decision)
   period: decisions up to 2026-10-30; state on 2026-10-30; filters: every remove decision in the review record; REMEDIATION_DAYS=14; run at 2026-11-01 19:57:52 UTC; rows: 5; sha256: 8ed9535b5de80a0246a1c941d4c1253554266875dc34a3316ff346945837ef48
   platform   | login             | access          | review_id | decided_on | reviewer         | held_now_by | account_enabled | gone_on    | status         
   databricks | dara@example.test | finance-readers | R-2026-09 | 2026-09-28 | lee@example.test |             | true            | 2026-09-29 | removed in time
   databricks | ivy@example.test  | sales-readers   | R-2026-09 | 2026-09-28 | lee@example.test |             | false           | 2026-09-29 | removed in time
   redshift   | jon               | base_ro         | R-2026-09 | 2026-09-28 | lee@example.test |             | no account      | 2026-09-30 | removed in time
   redshift   | jon               | data_rw         | R-2026-09 | 2026-09-28 | lee@example.test |             | no account      | 2026-09-30 | removed in time
   snowflake  | dara              | fin_analyst     | R-2026-09 | 2026-09-28 | lee@example.test |             | true            | 2026-09-30 | removed in time

Needs follow-up: no owner 0, timing 0, coverage 0, decisions 0; input checks failed: 0. The script produces evidence; the assessor decides whether the review was enough.

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

python3 pci_access_review_evidence.py --demo --plant writes the same records with problems planted. All 18 input checks pass; the lists find what was planted:

  • List 1: one Snowflake account with no HR or third-party match (NO OWNER).
  • List 2: the Snowflake reviews are 192 days apart (GAP OVER 184 DAYS); Redshift's only review is unacknowledged (NO ACKNOWLEDGMENT) and is 199 days old at the export (OVERDUE).
  • List 3: in Snowflake's packet of 8 rows, one inherited role was never decided on (NOT REVIEWED), one decision was made by the account's own holder (REVIEWED OWN ACCESS), and one has no reviewer (NO REVIEWER).
  • List 4: of five remove decisions, four are STILL HELD PAST DEADLINE (one on Snowflake, one on Databricks where the account is deactivated, and two on Redshift, where jon was renamed jon_x and kept the access) and one is REMOVED LATE: Databricks recorded the removal on 2026-10-20, after a refused attempt on 2026-09-29 that does not count.
   snowflake  | ghost             | sales_reader            | direct             | true    | NO OWNER    |                                                    |            |               | 2026-10-12   | NO OWNER
   snowflake  | vendor_bi         | sales_reader            | direct             | true    | third party | ExampleVendor Analytics (sponsor ana@example.test) |            |               | 2026-10-27   | ok      
   what a review records (SHA-256 of each platform's platform, login, access and held_how):
      snowflake: 10 rows, sha256 005e9859cffed42b213717c3ad5dbf63a8013672798de6fd95493ecebc846923
      databricks: 8 rows, sha256 7f124a598b548b03d4f3cec3a9ced28951c0922bbd454acdebf81982193f9658
      redshift: 8 rows, sha256 7327d2692c161b6fa51217cf68b168181e9123710f57bbea2c1022e9ba169bc5
   accounts with no HR or third-party match (NO OWNER): 1
== 2. Review timing (7.2.4: at least every six months)
   period: reviews completed up to 2026-10-30; filters: REVIEW_INTERVAL_DAYS=184; run at 2026-11-01 19:57:53 UTC; rows: 8; sha256: 66cf44f7945c60d7d3f7a5b7fa20fb94ad6d8e36a04e2f3e5d04d2be1d7f1411
   platform   | review_id               | completed  | days_since_previous | packet_sha256 | acknowledged_by  | acknowledged_on | status           
   snowflake  | R-2026-04               | 2026-03-20 |                     |               | lee@example.test | 2026-04-16      | ok               
   snowflake  | R-2026-09               | 2026-09-28 | 192                 | 6a52b4af9d20  | lee@example.test | 2026-09-30      | GAP OVER 184 DAYS
   snowflake  | (since the last review) | 2026-09-28 | 32                  |               |                  |                 | ok               
   databricks | R-2026-04               | 2026-04-14 |                     |               | lee@example.test | 2026-04-16      | ok               
   databricks | R-2026-09               | 2026-09-28 | 167                 | 733f41979e27  | lee@example.test | 2026-09-30      | ok               
   databricks | (since the last review) | 2026-09-28 | 32                  |               |                  |                 | ok               
   redshift   | R-2026-04               | 2026-04-14 |                     | df9f4d68b986  |                  |                 | NO ACKNOWLEDGMENT
   redshift   | (since the last review) | 2026-04-14 | 199                 |               |                  |                 | OVERDUE          

== 3. Review coverage (the latest review against the packet it was given)
   period: the latest review per platform up to 2026-10-30; filters: packet matched by its SHA-256; one decision per packet row; run at 2026-11-01 19:57:53 UTC; rows: 23; sha256: c75f0d6c70b9984832984941eb4b84ef9477d7912d6b9a8c62758fe494cd9af7
   showing the 3 row(s) that need a look; all 23 are in 3_review_coverage.csv
   platform  | review_id | login     | access       | decision | reviewer         | decided_on | status             
   snowflake | R-2026-09 | admin_sam | accountadmin | keep     | sam@example.test | 2026-09-28 | REVIEWED OWN ACCESS
   snowflake | R-2026-09 | ben       | sales_reader | keep     |                  | 2026-09-28 | NO REVIEWER        
   snowflake | R-2026-09 | chen      | analyst_base |          |                  |            | NOT REVIEWED       
   snowflake: 8 packet rows, 1 not reviewed, 1 reviewed by the account holder, 1 with no reviewer
   databricks: 9 packet rows, 0 not reviewed, 0 reviewed by the account holder, 0 with no reviewer
   redshift: 6 packet rows, 0 not reviewed, 0 reviewed by the account holder, 0 with no reviewer
   access rows in today's packet that were not in the latest review's packet (they go into the next review; a count to look at, not a finding): redshift 4; snowflake 2

== 4. Decisions carried out (every remove decision)
   period: decisions up to 2026-10-30; state on 2026-10-30; filters: every remove decision in the review record; REMEDIATION_DAYS=14; run at 2026-11-01 19:57:53 UTC; rows: 5; sha256: 040a964aea3f010a5e06f4fdae30cea748144bd991d05079206abfc8b0b394ac
   platform   | login             | access          | review_id | decided_on | reviewer         | held_now_by      | account_enabled | gone_on    | status                  
   databricks | dara@example.test | finance-readers | R-2026-09 | 2026-09-28 | lee@example.test |                  | true            | 2026-10-20 | REMOVED LATE            
   databricks | ivy@example.test  | sales-readers   | R-2026-09 | 2026-09-28 | lee@example.test | ivy@example.test | false           |            | STILL HELD PAST DEADLINE
   redshift   | jon               | base_ro         | R-2026-04 | 2026-04-14 | lee@example.test | jon_x            | true            |            | STILL HELD PAST DEADLINE
   redshift   | jon               | data_rw         | R-2026-04 | 2026-04-14 | lee@example.test | jon_x            | true            |            | STILL HELD PAST DEADLINE
   snowflake  | dara              | fin_analyst     | R-2026-09 | 2026-09-28 | lee@example.test | dara             | true            |            | STILL HELD PAST DEADLINE

Needs follow-up: no owner 1, timing 3, coverage 3, decisions 5; input checks failed: 0. The script produces evidence; the assessor decides whether the review was enough.

Step 6. Run it with faults planted in the inputs

python3 pci_access_review_evidence.py --demo --plant-input writes the clean records with faults planted in the inputs, and leaves REMEDIATION_DAYS unset. 17 of the 18 input checks fail, each naming what it found; lists 1 to 3 are marked INCOMPLETE and list 4 does not run. Because the edited Snowflake packet cannot be trusted, list 3 says Snowflake is NOT CHECKED rather than reading it.

What each input check caught in this run
Input checkPlanted fault that trips it
dates, interval and platforms in the settingsNot tripped in run 3. It fails if AS_OF is written 30/10/2026, REVIEW_INTERVAL_DAYS is 0, or PLATFORMS_EXPORTED names a platform that is not one of the three.
REMEDIATION_DAYS is setREMEDIATION_DAYS left unset: list 4 prints NOT RUN.
every file is there with every columnThe Redshift role-grants export came with granted_role_name renamed.
every date and time is readableA review dated 4/14/26 and another dated 2026-02-30.
every value is one the script knowsDISABLED set to maybe, a Snowflake TYPE of ROBOT, a decision of maybe, a SYS_USERLOG action of suspend.
every Databricks removal row is readableA delete row whose response is not JSON.
no login appears twice in an account listThe Snowflake user ANA listed twice.
no login is mapped to two peopleANA mapped to a second email.
no person twice in the HR list, no login twice in the third-party listOne employee listed twice.
every system_accounts login is an accountredshift/svc_billing, which is not an account.
every third_party_accounts login is an accountA Snowflake login that does not exist.
every login that holds access is in the account listA Snowflake grant to a user, ZED, who is not in the user list.
each review once per platform, none after the exportA review dated 2027-01-15.
each platform's latest review has its packet, and it hashes to the recorded SHA-256One row of Snowflake's packet file edited after the review.
no access has two decisions in one reviewANA's FIN_ANALYST decided as both keep and remove.
every decision belongs to a reviewA decision for review R-9999.
every decision is about a row of its packetA Databricks decision on an account that is not in the packet.
Databricks records reach back to every remove decision of the last 365 daysThe oldest audit row removed, so the records start after a decision.
Input checks
  [ok] dates, interval and platforms in the settings
  [FAIL] REMEDIATION_DAYS is set (PCI sets no deadline for addressing wrong access: it is yours): not set to a whole number of days: list 4 will not run
  [FAIL] every file is there with every column the script reads: redshift_svv_role_grants.csv has no column granted_role_name
      read 9 rows into snowflake_users
      read 10 rows into snowflake_grants_to_users
      read 4 rows into snowflake_grants_to_roles
      read 6 rows into databricks_users
      read 1 rows into databricks_service_principals
      read 7 rows into databricks_group_members
      read 5 rows into databricks_audit
      read 5 rows into redshift_svv_user_info
      read 3 rows into redshift_svv_user_grants
      read 0 rows into redshift_svv_role_grants
      read 5 rows into redshift_sys_userlog
      read 11 rows into hr_people
      read 18 rows into login_map
      read 2 rows into third_party_accounts
      read 3 rows into system_accounts
      read 8 rows into reviews
      read 26 rows into review_decisions
  [FAIL] every date and time is written as YYYY-MM-DD [HH:MM:SS] (UTC): not readable, so left out: reviews.completed_date: 2
  [FAIL] every value is one the script knows: snowflake_users.disabled not true or false: 1; snowflake_users.type not a type the script knows: 1; review_decisions.decision not keep or remove: 1; redshift_sys_userlog.action not alter, create, drop or rename: 1
  [FAIL] every Databricks removal row has a readable response status and workspace_id: 1 row(s) whose response is not JSON with a status code
  [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] no person appears twice in the HR list, and no login twice in the third-party list: ana@example.test
  [FAIL] every login in system_accounts is an account on its platform: matches no account: redshift/svc_billing
  [FAIL] every login in third_party_accounts is an account on its platform: matches no account: snowflake/no_such_vendor_login
  [FAIL] every login that holds a role or group is in its platform's account list: holds access, not in the account list: snowflake/zed
      Databricks group members that are no user, service principal or group in the export: 1
      Snowflake grants older than the user who now has that name (an earlier holder's; left out): 0
  [FAIL] each review appears once per platform and none is dated after the export: after 2026-10-30: snowflake/R-2027-01
  [FAIL] each platform's latest review has its packet file, and it still hashes to the recorded SHA-256: snowflake/R-2026-09: packet_snowflake_6a52b4af9d2052aa1cf2f71b4227270dd194908b6d53fd9bf7b5398b1606809b.csv was edited (its rows hash to 1ffbf897f57e...)
  [FAIL] no access has two decisions in one review: snowflake/R-2026-09/ana/fin_analyst
  [FAIL] every decision belongs to a review in the review list: no such review: redshift/R-9999
  [FAIL] every decision is about a row of the packet its review was given: not in the packet: databricks/R-2026-09/nobody@example.test/finance-readers
  [FAIL] Databricks account records reach back to every remove decision of the last 365 days: earliest such remove decision 2026-09-28; earliest 'accounts' audit row 2026-09-29

18 input checks, 17 failed.  ** INCOMPLETE: 17 input check(s) failed; read the lists with that in mind **
== 3. Review coverage (the latest review against the packet it was given)
   period: the latest review per platform up to 2026-10-30; filters: packet matched by its SHA-256; one decision per packet row; run at 2026-11-01 19:57:53 UTC; rows: 15; sha256: 911c32340af28f392539f2eab80ffeaabcb9515b5ddfbec3c7e0442f34639467; INCOMPLETE
   showing the 0 row(s) that need a look; all 15 are in 3_review_coverage.csv
   snowflake: NOT CHECKED: no latest review with a packet that could be read
   databricks: 9 packet rows, 0 not reviewed, 0 reviewed by the account holder, 0 with no reviewer
   redshift: 6 packet rows, 0 not reviewed, 0 reviewed by the account holder, 0 with no reviewer
   access rows in today's packet that were not in the latest review's packet (they go into the next review; a count to look at, not a finding): databricks 1; redshift 1

== 4. Decisions carried out (every remove decision)
   NOT RUN: REMEDIATION_DAYS is not set

Needs follow-up: no owner 0, timing 0, coverage 0, decisions not run; input checks failed: 17. The script produces evidence; the assessor decides whether the review was enough.

How to read the results

  • Input checks first. If any check failed, the lists say INCOMPLETE. A list built on a record with an unreadable date or an edited packet is not a record to rely on until the input check is fixed.
  • List 1 is the packet. It is what the reviewer should have been given. Record its SHA-256 on the review's row. Later you can check that the packet file you kept is the one the review recorded; the hash shows which file that was, not what the reviewer looked at. NO OWNER is a question for whoever runs the platform: an account nobody can name is hard to review.
  • List 2 shows dates, not a verdict. A gap over 184 days is a question for the review's owner. The standard says a late activity is not automatically a failure where a documented process caught and handled it; the assessor decides, not the script.
  • List 3 shows what the review decided on. NOT REVIEWED is a packet row with no decision. The packet is the script's own and may hold rows your reviewer reasonably treated as one (an inherited role decided with its parent); a flag is a question, not a finding. REVIEWED OWN ACCESS means the reviewer was the account's owner.
  • List 4 is the one to read first. A remove decision whose access is still there, past the days you set, is a decision the platform's records do not show as carried out. Check STILL HELD rows against the account state first: a deactivated account still holds the group.
  • Not checked is not clean. A platform left out of PLATFORMS_EXPORTED is printed as not checked, and list 4 prints NOT RUN while REMEDIATION_DAYS is blank.

Run it on your own data

Save each export query's result as a CSV named as in FILES, in one folder, with the platform's own column names (a header in capitals is fine), and write your own lists (hr_people, login_map, third_party_accounts, system_accounts, reviews and review_decisions) as CSVs with the columns FILES names. Put each review's packet file, as the script wrote it to the packets folder of its run in evidence-out, in a folder named packets inside that folder. Then change four lines at the top of the script: set EXPORT_DIR to the path of that folder, AS_OF to the day you took the exports, PLATFORMS_EXPORTED to the platforms you exported, and REMEDIATION_DAYS to the number of days you allow. Change nothing else.

We tested those steps on a second, separate set of generated exports: other logins in mixed case (a login written Mia.Ortiz in one file and MIA.ORTIZ in another), upper-case Snowflake headers, a Snowflake user with an empty TYPE, a blank email in the login map, a blank reviewer, 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 17 input checks with none failing, and reported one account with no owner, three timing flags (a Snowflake gap of 211 days, a Redshift review nobody acknowledged, and Redshift overdue at 243 days), three coverage flags and two remove decisions still held past the 10 days that test set. That test is also generated data.

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

Method

  • Input: generated records written by the script as CSV with column names taken from each platform's documentation (the links in step 1), with fixed rows and no randomness. The HR list, the login map, the third-party and system account lists and the review record are generated too. The people, roles and counts are invented.
  • Dates: the demo export day is 2026-10-30, UTC. The clean run's reviews are on 2026-04-14 and 2026-09-28; the planted run moves some of them, as the step says.
  • Fingerprints: each list is sorted on every column before it is hashed, so the same input gives the same SHA-256 on every run. We ran the clean demo three times and got the same list hashes.
  • Our assumptions, to check against one of your own rows: a Snowflake role-in-role grant is a GRANTS_TO_ROLES row with GRANTED_ON ROLE and PRIVILEGE USAGE; a Snowflake grant with no DELETED_ON to a dropped user does not stay attached to a new user of the same name, unless it was created after that user (the page does not say); a Databricks group member's value is the member's id, and a member that is another group's id is a nested group; a successful Databricks row has the status 200 in response; the Databricks audit rows carry targetUserName and targetGroupName in request_params, one user per row (the reference does not give the format for removePrincipalsFromGroup, which can remove several users at once: if it lists them together, that removal shows as GONE, DATE NOT RECORDED); 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.
  • PCI sets the questions, not your numbers. The deadline for addressing wrong access is yours. The script cannot tell you whether your numbers are right, whether a late review was handled by a documented process, or whether the review as a whole was enough. The assessor decides.
  • The review itself is not in the platforms. The script checks the review's own record against what the platforms hold. It cannot tell whether a reviewer thought about a row, or whether management really confirmed it.
  • The records may not hold what you need. Redshift's role revokes have no date in the views we read, and its user log may keep only about seven days, or fewer (the AWS reference gives seven days for STL views and none for this one), so keep copies. Snowflake's history is kept for 365 days. Databricks keeps its audit table for 365 days, and workspace-level events stay in each workspace's region. Databricks group members must be collected group by group, and a member the script cannot place is only counted. Last sign-in is read for Snowflake only. A Snowflake ALTER USER ... RENAME TO is not in these exports, so a renamed Snowflake user is followed only if the login map names both logins.
  • Access outside the holdings read here is not in the packet: object privileges granted straight to a user, Snowflake database roles, Redshift user groups, the Databricks account admin role and workspace-local groups (each workspace's admins group included), a Redshift superuser's reach beyond the flag, and access in tools outside the three platforms. An account shown as (no role or group read) may still hold access through one of them.
  • Packet identity covers four columns. The SHA-256 covers each row's platform, login, access and how it is held. The owner, department and job function printed beside them are not part of it.
  • Read, not enforced. The script reads exports. It never connects to a platform and never changes an account.
  • Evidence, not a verdict. The script produces evidence. It never says a requirement 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 (the same output on both); Python 3
Date
2026-11-01
Dataset
Generated records (fixed rows) with column names taken from each platform's documentation; 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.
  • A review that has no record. If the review lives in email threads, there is no reviews.csv to check, and writing one is the first job.
  • A policy that is silent. If your policy has no time for removal, 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; each run keeps its own folder of lists, packets and hashes.
  • Platforms other than these three, and the rest of requirement 7 and the other requirements of the standard.

Get the free script and see your own review gaps

The script is free. It reads CSV exports from your platforms and your own review record. It only reads, and it never writes.

Seeing access changes every day is a different job. 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. No platform passwords stored.

See how Data Control Tower works