Show an auditor who approved each person's data access, and why
The short answer
A Python script reads five files: exports from your data platform and your Microsoft Entra tenant, and a role list you keep. For each access, it writes who asked and why, who approved and why, and when it ends. Where that record is missing, it names the gap. A person still reviews the result. The demo data is generated.
What you will have at the end
A Python script, who_approved_each_grant.py, and a file it writes, grant_evidence.csv, with one row per grant. A grant here is one person holding one role on your data platform today. For each grant the file holds the request, the reason the person gave, the approver, the reason the approver gave, the time of the approval and the date the access ends, or the name of each gap. The same steps with more detail are in Download the full guide (PDF).
The script reads CSV files and writes only its own output files. It never connects to your data platform or your Entra tenant, and it never changes either. With --demo it also downloads one public file from GitHub. It does not decide whether access was appropriate. It shows whether the chain of records exists: a delivered request, a named approver who is not the requester, a reason from each, an approval that came before the grant, and an end date that has not passed. A person reads what it prints.
The records in this guide are generated (seed 124): invented people with example.com addresses, not a company’s data. The 22 Entra column names are Microsoft Graph’s own property names, and with --demo the script checks that before it runs (step 1).
Step 1. Put the five lists in five CSV files
Microsoft Entra entitlement management keeps a request for each access package assignment. The request carries the requester’s justification (The requestor’s supplied justification
, per the accessPackageAssignmentRequest documentation (read 2026-11-04)). The approval is a separate record whose stages carry the reviewer, the result (NotReviewed, Approved or Denied), the time and the reviewer’s justification (approvalStage (read 2026-11-04)). The assignment carries a schedule whose expiration holds an end date or says there is none (expirationPattern (read 2026-11-04)). Stages are listed per request by List approval stages (read 2026-11-04), and a single request is read with Get accessPackageAssignmentRequest (read 2026-11-04).
The script takes five files. Nested Graph properties are flattened by joining the names with an underscore, so requestor.objectId becomes requestor_objectId and assignment.target.principalName becomes assignment_target_principalName.
| File | Columns the script reads | Where they come from |
|---|---|---|
grants.csv | grantee_name, role, created_on | Your platform’s current grants. Snowflake’s GRANTS_TO_USERS view has these three columns (read 2026-11-04). If your platform exports these three, map them in COLUMN_MAP. The script expects the grantee to be the sign-in name that Entra calls principalName. |
role_packages.csv | role, access_package_id | A list you keep: which Entra access package gives which role. |
requests.csv | id, requestType, state, createdDateTime, completedDateTime, justification, requestor_objectId, assignment_id, assignment_target_principalName, accessPackage_id | accessPackageAssignmentRequest, with its assignment expanded (it supports $expand): the assignment the request creates or, for a removal, the one it ends. The requester can be someone else: for an admin’s request it is the admin. |
approvals.csv | request_id, reviewedBy_id, reviewedBy_displayName, reviewResult, reviewedDateTime, justification | approvalStage, one row per stage; request_id is the request the stage belongs to. |
assignments.csv | id, accessPackage_id, target_principalName, state, schedule_startDateTime, schedule_expiration_type, schedule_expiration_endDateTime | accessPackageAssignment |
With --demo the script downloads Microsoft’s published Graph metadata at a pinned commit (microsoftgraph/msgraph-metadata at commit c4b98b7b), stops if the file’s size or SHA-256 differs from the pinned one, and stops if any of the 22 columns it takes from Graph is not a property in it. Run 1 below prints that check.
Step 2. Let the input checks run first
The script runs eight checks on the files before it looks at a single grant: the date, that every file and column is there, that every date and time is readable, that every grant names a grantee and a role, that every request id appears once, that every request state, request type, result, expiration type and assignment state is one Microsoft documents, that every role in the role map holds a grant, and that every role maps to one access package. If any fails, the run says INCOMPLETE and computes nothing, because a report built on files it could not read would look clean for the wrong reason. Run 3 shows this.
Step 3. Match each grant to its request, its approvals and its end date
For each grant the script finds the delivered request that assigned that person the access package the role maps to, whether the person asked or an admin assigned it, preferring the one completed just before the grant. Then it counts that request’s approvals by a named reviewer, how many of them are by the requester, how many have no reason, and when the last one was recorded. This is the matching query, copied from the script:
WITH cand AS (
SELECT g.grant_no, r.id AS request_id, r.oid, r.created, r.justification AS req_reason,
row_number() OVER (PARTITION BY g.grant_no ORDER BY coalesce(r.completed > g.granted_at, TRUE),
abs(epoch(r.completed) - epoch(g.granted_at)), r.id) AS rk
FROM g JOIN pkg p ON p.role = g.role JOIN req r ON r.principal = g.grantee AND r.package = p.package)
SELECT c.grant_no, c.request_id, c.oid, c.created, c.req_reason,
(SELECT count(*) FROM apr a WHERE a.request_id = c.request_id AND a.reviewer IS NOT NULL) AS n_appr,
(SELECT count(*) FROM apr a WHERE a.request_id = c.request_id AND a.reviewer = c.oid) AS n_self,
(SELECT count(*) FROM apr a WHERE a.request_id = c.request_id AND a.reviewer IS NOT NULL
AND a.justification IS NULL) AS n_blank,
(SELECT max(a.at) FROM apr a WHERE a.request_id = c.request_id AND a.reviewer IS NOT NULL) AS last_appr,
(SELECT string_agg(DISTINCT coalesce(a.reviewer_name, a.reviewer), '; ') FROM apr a
WHERE a.request_id = c.request_id AND a.reviewer IS NOT NULL) AS approvers,
(SELECT string_agg(coalesce(a.justification, '(none)'), ' | ') FROM apr a
WHERE a.request_id = c.request_id AND a.reviewer IS NOT NULL) AS approver_reasons
FROM cand c WHERE c.rk = 1
Each finding is one line of a second query. Here are the first lines of it and the last one (the full set is in the download):
SELECT grant_no, 'UNMAPPED_ROLE' AS code, 'role ' || role_shown || ' is not in role_packages' AS detail
FROM m WHERE NOT mapped
UNION ALL SELECT grant_no, 'NO_REQUEST', 'no delivered request for this person and package' FROM m
WHERE mapped AND NOT has_req
UNION ALL SELECT grant_no, 'NO_APPROVAL', 'request ' || request_id || ' has no approval by a named reviewer'
FROM m WHERE has_req AND n_appr = 0
UNION ALL SELECT grant_no, 'SELF_APPROVAL', 'request ' || request_id || ' was approved by the person who asked'
FROM m WHERE has_req AND n_self > 0
...
UNION ALL SELECT grant_no, 'PAST_END_DATE', 'ended ' || strftime(ends_at, '%Y-%m-%d') || ', grant still present'
FROM m WHERE mapped AND ends_at < {asof}
An approval with a blank reviewer counts as no approval: an approval that does not say who gave it cannot answer the auditor’s question. A grant whose role is not in the role map gets only UNMAPPED_ROLE, because without a package the script has no request to look for. Reason fields count as blank after trimming spaces.
| Finding | What it says | What a person does with it |
|---|---|---|
NO_REQUEST | No delivered request that assigned this person the access package. | Find out how the grant was made, then request it properly or remove it. This is the finding for grants that did not come through a delivered request. |
NO_APPROVAL | The request has no approval stage with result Approved and a named reviewer. | Ask the owner to decide on the record. Entra can assign access without an approval: the request’s parameters can bypass the approval requirement (accessPackageAssignmentRequest (read 2026-11-04)), so a request that skipped approval shows up here, and an assignment with no request shows as NO_REQUEST. |
SELF_APPROVAL | A reviewer’s id equals the requester’s object id. | Have a second person review the access. |
NO_REASON | The requester or an approver left the justification blank. | Ask for the reason now and file it with the request. |
GRANT_BEFORE_APPROVAL | The grant was created before the last approval was recorded. | Check for a side-door grant that was approved afterwards. |
NO_END_DATE | The person’s most recent assignment for this package holds no end date, or there is none. | Set an end date, or record why this access has none. |
REMOVED_IN_ENTRA | The person’s most recent assignment for this package is not active in Entra: its state is something other than delivered (expired, for example), or a delivered removal request names it. The grant is still there. | Check the assignment in Entra. If it expired or was removed, remove the grant on the platform, because the access ended in Entra but not where the data is. |
PAST_END_DATE | The end date is before AS_OF and the grant is still there. | Remove the grant or renew it through a new request. |
UNMAPPED_ROLE | The role is not in role_packages.csv. | Add the role to the map, or treat it as a grant with no request. |
Run 1: ten grants with a gap, planted
We ran the script on 2026-11-04 with DuckDB 1.5.6. --plant-error changes ten of the 30 generated grants so that each of the nine findings has something to find: grant 3 has no request, 7 no approval, 9 an assignment that Entra ended (its state is expired and an admin’s removal request names it) while the grant stayed, 11 an approver who is the requester, 15 an approver with no reason, 16 a requester with no reason, 19 a grant two days before its approval, 22 no end date, 25 an end date that has passed, and 28 a role missing from the map. The script stops before planting if a planted value already occurs in the clean records, or if a request or assignment row it changes or removes, or an approval row it edits, is not found exactly once. Grant 3 has two findings: it has no request, and so no assignment either.
python3 guides/prove-who-approved-each-data-access/who_approved_each_grant.py --demo --plant-error --out loop-out/data/wa
Output from our run 1
Demo: checking the generated records' column names against Microsoft Graph's metadata
Graph metadata at the pinned commit: 8200681 bytes, SHA-256 ff4f707d562dd1aa...
Checked 22 columns against Graph's own property names; not found: 0
Generated records written to loop-out/data/wa/generated-planted-error/ (seed 124; not a company's data).
Run at 2026-11-04 10:48:01 UTC. Exports taken 2026-11-04 (UTC).
Input checks
[ok] AS_OF is a real date written YYYY-MM-DD
[ok] every file is there with every column the script reads
read 30 rows from grants.csv
read 6 rows from role_packages.csv
read 30 rows from requests.csv
read 28 rows from approvals.csv
read 29 rows from assignments.csv
[ok] every date and time is written as YYYY-MM-DD[T]HH:MM:SS (UTC)
[ok] every grant names a grantee and a role
[ok] every request id is there once
[ok] every state, type and result is one Microsoft documents
[ok] every role in role_packages holds at least one grant
[ok] every role maps to one access package
Findings (one line per gap; a grant can have more than one)
grant 3 chloe.jones@example.com HR_ANALYST NO_END_DATE no assignment for this person and package
grant 3 chloe.jones@example.com HR_ANALYST NO_REQUEST no delivered request for this person and package
grant 7 gwen.jones@example.com FINANCE_READER NO_APPROVAL request req-007 has no approval by a named reviewer
grant 9 iris.jones@example.com HR_ANALYST REMOVED_IN_ENTRA assignment asg-009 is expired; removed by request req-031 (adminRemove)
grant 11 kira.jones@example.com MARKETING_READER SELF_APPROVAL request req-011 was approved by the person who asked
grant 15 omar.jones@example.com HR_ANALYST NO_REASON request req-015: no reason from the approver
grant 16 petra.smith@example.com OPS_READER NO_REASON request req-016: no reason from the requester
grant 19 sven.jones@example.com FINANCE_READER GRANT_BEFORE_APPROVAL granted 2026-09-09 15:50, last approval 2026-09-11 15:50
grant 22 ben.smith@example.com OPS_READER NO_END_DATE assignment type noExpiration holds no end date
grant 25 elena.jones@example.com FINANCE_READER PAST_END_DATE ended 2026-10-15, grant still present
grant 28 hugo.smith@example.com LEGACY_ADMIN UNMAPPED_ROLE role LEGACY_ADMIN is not in role_packages
Counts by finding
NO_REQUEST 1
NO_APPROVAL 1
SELF_APPROVAL 1
NO_REASON 2
GRANT_BEFORE_APPROVAL 1
NO_END_DATE 2
REMOVED_IN_ENTRA 1
PAST_END_DATE 1
UNMAPPED_ROLE 1
30 grants read: 20 matched to a request, an approver, a reason and an end date; 10 with at least one finding.
Evidence for every grant written to loop-out/data/wa/grant_evidence-planted-error.csv (30 rows).
Three matched grants, as an auditor would read them
grant 1: ada.jones@example.com holds FINANCE_READER; asked 2026-08-05 10:11:00 (Quarter-end close reconciliation); approved 2026-08-06 14:11:00 by Tess Owner (Approved: role matches the stated task, review in 180 days); access ends 2027-02-02
grant 2: ben.smith@example.com holds SALES_READER; asked 2026-08-07 09:45:00 (Pipeline review for the regional plan); approved 2026-08-08 10:11:00 by Uma Owner (Approved: owner confirmed the need with the team lead); access ends 2027-02-04
grant 4: dev.smith@example.com holds OPS_READER; asked 2026-08-11 10:21:00 (Throughput study for the new site); approved 2026-08-12 11:46:00 by Wes Owner (Approved: role matches the stated task, review in 180 days); access ends 2027-02-08
Ten of 30 grants are flagged, with eleven findings between them. The other 20 each get a full evidence row in grant_evidence.csv; the last block prints three of them as an auditor would read them: who asked and why, who approved and why, and the date the access ends.
Run 2: the clean records
The same script on the same seed without --plant-error. Every grant matches, and no finding is printed. On these complete records, no check fires. Six of the 30 requests were made by an admin (adminAdd), so the requester is not the person who got the access; they match like the rest, because the script matches on the person the request assigns.
python3 guides/prove-who-approved-each-data-access/who_approved_each_grant.py --demo --out loop-out/data/wa
Output from our run 2 (from the findings on)
Findings (one line per gap; a grant can have more than one) none Counts by finding NO_REQUEST 0 NO_APPROVAL 0 SELF_APPROVAL 0 NO_REASON 0 GRANT_BEFORE_APPROVAL 0 NO_END_DATE 0 REMOVED_IN_ENTRA 0 PAST_END_DATE 0 UNMAPPED_ROLE 0 30 grants read: 30 matched to a request, an approver, a reason and an end date; 0 with at least one finding. Evidence for every grant written to loop-out/data/wa/grant_evidence.csv (30 rows). Three matched grants, as an auditor would read them grant 1: ada.jones@example.com holds FINANCE_READER; asked 2026-08-05 10:11:00 (Quarter-end close reconciliation); approved 2026-08-06 14:11:00 by Tess Owner (Approved: role matches the stated task, review in 180 days); access ends 2027-02-02 grant 2: ben.smith@example.com holds SALES_READER; asked 2026-08-07 09:45:00 (Pipeline review for the regional plan); approved 2026-08-08 10:11:00 by Uma Owner (Approved: owner confirmed the need with the team lead); access ends 2027-02-04 grant 3: chloe.jones@example.com holds HR_ANALYST; asked 2026-08-09 10:11:00 (Headcount analysis for the annual plan); approved 2026-08-10 12:13:00 by Vic Owner (Approved: the project named in the request needs this role); access ends 2027-02-06
Run 3: the input checks stop a bad export
--plant-input plants eight faults in the generated files, aimed at seven of the eight input checks: the header of approvals.csv renamed (justification to reason), a grant date written 03/04/26, a grant with no grantee, one request id twice, a request state Graph does not define (approved), an expiration type Graph does not define (forever), a role in the map that no grant holds, and one role listed twice. One check (every state, type and result is one Microsoft documents) catches two of the faults, so seven of the eight input checks fail. The one that passes is the date in the settings.
python3 guides/prove-who-approved-each-data-access/who_approved_each_grant.py --demo --plant-input --out loop-out/data/wa
Output from our run 3
Demo: checking the generated records' column names against Microsoft Graph's metadata
Graph metadata at the pinned commit: 8200681 bytes, SHA-256 ff4f707d562dd1aa...
Checked 22 columns against Graph's own property names; not found: 0
Generated records written to loop-out/data/wa/generated-planted-input/ (seed 124; not a company's data).
Run at 2026-11-04 10:48:04 UTC. Exports taken 2026-11-04 (UTC).
Input checks
[ok] AS_OF is a real date written YYYY-MM-DD
[FAIL] every file is there with every column the script reads: approvals.csv has no column justification
read 30 rows from grants.csv
read 8 rows from role_packages.csv
read 31 rows from requests.csv
read 0 rows from approvals.csv
read 30 rows from assignments.csv
[FAIL] every date and time is written as YYYY-MM-DD[T]HH:MM:SS (UTC): not readable: grants.csv.created_on: 1
[FAIL] every grant names a grantee and a role: 1 grant row(s) with no grantee or no role
[FAIL] every request id is there once: 1 request row(s) repeat an id or have none
[FAIL] every state, type and result is one Microsoft documents: unknown values: requests.csv.state: 1, assignments.csv.schedule_expiration_type: 1
[FAIL] every role in role_packages holds at least one grant: 1 role(s) match no grant: a typo or a stale row
[FAIL] every role maps to one access package: 1 role(s) listed more than once
INCOMPLETE: 7 input check(s) failed, so no findings are computed. Fix the input.
How to read the results
- A finding is a question, not a verdict. It says a record is missing or out of order. The access may still be right; the record that shows it is not there.
- A matched grant is a complete chain, not a good decision. The script does not know whether the approver was the right owner or the reason was a good one. A person reads the reasons in
grant_evidence.csv. - Check the counts add up. The summary line gives grants read, grants matched and grants with a finding; they must add up to the rows of
grants.csv, and so must the rows ofgrant_evidence.csv. - Keep the file with the date of the export. The evidence is as of
AS_OFand of the exports you took.
Run it on your own data
To run it on your own data, change four settings at the top of the script and nothing else. Set DATA_DIR to the folder that holds your five CSV files. Set FILE_NAMES to the names of those files. In COLUMN_MAP, change the name on the right of each line to the column name in your file; leave the names on the left as they are, and names are matched without regard to case. Set AS_OF to the day you took the exports. Export only current grants (for Snowflake, the rows of GRANTS_TO_USERS where DELETED_ON is empty), list in the role map only roles that hold a grant, write every date and time as YYYY-MM-DD HH:MM:SS in UTC (a T between date and time, fractions of a second and a Z, +00:00 or +0000 ending are accepted), and save every file as CSV with a header row. Export each request with its assignment expanded, so assignment_id and assignment_target_principalName are filled, and each assignment with its state. Then run the script with no options.
We tested those sentences on a reader’s data built for the purpose: other file names, other column names in mixed case, a grantee typed in capitals, a reviewer id in other letter case, a request an admin made, a request row with no requester, an approval with no named reviewer, a reason that starts with an equals sign, and at least one gap for each of the nine findings (a small invented set; that run’s output is not reproduced here). Done literally, the script printed all nine and nothing failed.
Download: who_approved_each_grant.py · Download the full guide (PDF)
Method
- Records: generated with Python’s
random.Random(124). Thirty grants, six roles, twenty people who hold access, one admin who made six of the requests, five approvers, all invented, allexample.comaddresses. Each assignment ends 180 days after its request was delivered. Nothing in them is a measurement of any company or platform. - Column names: taken from microsoftgraph/msgraph-metadata at commit c4b98b7b, the Graph v1.0 metadata file (8,200,681 bytes, SHA-256
ff4f707d562dd1aa1dfaa591f4e17f4e6f50d517dc65b0278d216fb4fc4e547b). The script stops if the downloaded file differs. The Snowflake-style grant columns come from GRANTS_TO_USERS (read 2026-11-04). - Matching rule: a request counts if its
stateisdelivered, itsrequestTypeisuserAdd,adminAddoronBehalfAdd(Microsoft’s v1.0 page marksonBehalfAddnot supported; it is counted if it appears), and the person it assigns (assignment.target.principalName) and its access package equal the grant’s grantee and the package the role maps to. Names are compared without regard to case. If several match, the one completed closest before the grant is used; if none was completed before it, the closest after. - Approval rule: a stage counts if its
reviewResultisApprovedand its reviewer is named. A reviewer id of all zeros, which Microsoft documents for a stage nobody reviewed (approvalStage (read 2026-11-04)), counts as not named. Ids are compared without regard to case. - Removal rule: a grant is reported
REMOVED_IN_ENTRAif the person’s most recent assignment for the package has a state other thandelivered, or a delivered request of typeuserRemove,adminRemove,systemRemoveorapproverRemovenames that assignment’s id. Microsoft documents that for a removal the request’sassignmentholds the id of the assignment it removes (accessPackageAssignmentRequest (read 2026-11-04)). - Checks and what trips each, in the order of the findings:
NO_REQUEST(grant 3),NO_APPROVAL(7),SELF_APPROVAL(11),NO_REASON(15 and 16),GRANT_BEFORE_APPROVAL(19),NO_END_DATE(22, and 3),REMOVED_IN_ENTRA(9),PAST_END_DATE(25),UNMAPPED_ROLE(28). Input checks: file and column present (renamed header), date readable (03/04/26), grantee and role named (blank grantee), request ids once (repeated id), values Microsoft documents (approved,forever), role map holds a grant (a stale role), one package per role (a role twice). The date setting check is tripped by anAS_OFthat is not a date; no run plants that. - Not a check: if the grants the script numbered differ from the grants it read, it stops with an internal error. No input of ours trips it, and it is not a check on your data.
- Mapping tables written out: the known request states, request types, results, expiration types and assignment states, and the request types the script counts as creating or ending an assignment, are lists at the top of the script (values copied from Microsoft’s documentation (read 2026-11-04)); edit them if you disagree.
- Spreadsheet safety: a text cell in
grant_evidence.csvthat starts with=,+,-,@, a tab or a carriage return gets a'in front, so a spreadsheet shows it as text and does not run it as a formula. Reasons are typed by requesters, so this matters.
Limits
- This is not a client result and not a measurement. The records are generated to show the check. They say nothing about how often real grants lack a request.
- It checks that a record exists, not that the decision was right. A named approver may not own the data, and a reason may be one word. A person has to read them.
- It only knows the approvals you export. Access given outside Entra, or approved by email or in a ticket, shows up as
NO_REQUESTeven if someone said yes. To count an approval given by email, add it to your requests and approvals files and re-run, or accept the finding. - It matches by sign-in name and role. If your platform names people differently from Entra’s
principalName, map them in the export first. A grant held through a group or a role nested in another role needs the export to list the person and the role they end up holding. - Request history may not reach back far enough. A grant older than the history you export has no request in the files and is reported as
NO_REQUEST. - An assignment that carries a duration and no end date is reported as
NO_END_DATE, because the script reads onlyschedule_expiration_endDateTime. Export the end date, or work it out from the start and the duration, before you run it. - It does not change anything. It does not remove a grant, ask for a reason or end access.
- Evidence, not a conclusion. What it prints is one input to a review. It does not say any review or framework is satisfied.
Tested on
Tested on
- Platform
- DuckDB 1.5.6, Python 3.12, run from Python with the
duckdbmodule - Edition
- Open-source DuckDB, in-memory
- Date
- 2026-11-04
- Dataset
- Generated records (seed 124), shaped like Microsoft Graph v1.0 and checked against its published metadata at commit c4b98b7b7808ee5f4e7d752378339d70ef472154. Not real data, and not a run on Entra or on any data platform.
- Code
- who_approved_each_grant.py
When this is not enough
- The record is not there to find. If requests and approvals happened in email and tickets, the script lists the gaps and cannot fill them. Someone has to decide, now, who approves and why, and write it down.
- Request history is gone. The script can only see the requests you exported. Export them on a schedule, so the history is there when the review starts.
- You need a person to judge the reasons. A complete chain can still hold a weak reason or the wrong approver. That takes the data owner.
- Finding is not fixing. The next step is requests that always carry an owner’s yes, a reason and an end date, so the gaps do not come back.
Have the record exist before anyone asks for it
The script finds gaps after the fact. Data Access Concierge makes the record at the start: every permission it gives carries a reason, the owner’s yes and an end date. People ask for data on the catalog page, and the owner approves in one screen.
Data Meaning sets it up and runs it in your own environment. Access given by hand outside it still needs a separate check.