Check that every Microsoft Entra access package ends access on a set date
The short answer
Save your assignment policies and assignments from Microsoft Graph, then run the script. It lists every access package policy and assignment with no end date, a term longer than your maximum, a missed expiry, or an expiry it cannot read. It reads the files you save and changes nothing in Entra.
What you will have at the end
A Python script, entra_end_date_check.py, that reads files you save from Microsoft Graph and lists every Microsoft Entra access package policy, and every assignment, that does not end access on a set date. It runs in DuckDB, reads the files you save and changes nothing in your tenant. Once per run it also downloads Microsoft’s published Graph metadata (8,200,681 bytes) to check property names; none of your data is sent, and --no-schema-check skips the download, in which case the result is INCOMPLETE. Download the full guide (PDF) for the same steps with the complete code and both runs in one document.
Two words. A policy is the rule on an access package. Microsoft describes an access package assignment policy as specifying “the policy by which subjects can request or be assigned an access package”, and its expiration property as “The expiration date for assignments created in this policy” (Microsoft Graph: accessPackageAssignmentPolicy (read 2026-11-04)). An assignment is a person’s actual access: “an assignment of an access package to a particular subject and time range” (Microsoft Graph: accessPackageAssignment (read 2026-11-04)).
The script checks both, because a policy with an end date does not guarantee that each assignment has one. Microsoft’s lifecycle page notes: “If you change the expiration date for assignments on a policy, the expiration date for requests that are already in a pending approval or approved state won’t change” (Microsoft Learn: change lifecycle settings for an access package (read 2026-11-04)). A corrected policy can sit next to requests that kept the old date, which is one reason the script reads assignments as well. We did not test which other paths leave an assignment without an end date; the three assignment faults in Run 1 are planted to show that the check catches them.
The records in the two runs below are generated with a fixed seed, shaped as Graph returns them and not taken from any tenant. The Tested on box says so again.
Step 1. Save the two files from Microsoft Graph
Call the list of assignment policies, GET /identityGovernance/entitlementManagement/assignmentPolicies (Microsoft Graph: list assignmentPolicies (read 2026-11-04)), and the list of assignments, GET /identityGovernance/entitlementManagement/assignments?$expand=accessPackage,assignmentPolicy (Microsoft Graph: list accessPackageAssignments (read 2026-11-04)). Microsoft lists EntitlementManagement.Read.All as the least privileged permission for both. For a directory-wide administrator, Microsoft says the assignments list includes current and expired assignments. Save each answer as one JSON file.
The script reads the value list in each file. If an answer ends with an @odata.nextLink, the list continues on another page: the script does not guess, it reports the file as incomplete (Step 4). The script reads these properties and no others: on a policy id, displayName, expiration and requestorSettings.allowCustomAssignmentSchedule; on an assignment id, state, schedule, accessPackage.displayName and assignmentPolicy.id. It never reads the person an assignment belongs to.
Step 2. Decide what counts as an end date
Graph describes an expiration with an expirationPattern: a type, plus a duration or an endDateTime (Microsoft Graph: expirationPattern (read 2026-11-04)). The script turns each type into a result. MAX_DAYS is your own rule; Microsoft’s lifecycle page lets an administrator set a number of days from 0 to 3660, or Never (Microsoft Learn: lifecycle settings (read 2026-11-04)).
| Type in Graph | What Microsoft says | Result in the script |
|---|---|---|
noExpiration | “The requestor did not wish the access to expire.” | NO END DATE |
notSpecified | “No expiration schedule was specified.” | NO END DATE |
afterDuration | “Access will expire after a specified duration relative to access being granted.” | TOO LONG if the duration is longer than MAX_DAYS; otherwise OK. A duration in months or years is not read: EXPIRATION NOT READ. |
afterDateTime | “Access will expire after a specified date and time.” | END DATE PASSED if on or before AS_OF; TOO LONG if later than AS_OF plus MAX_DAYS; otherwise OK. |
| anything else, or missing | EXPIRATION NOT READ. Never OK. |
Microsoft’s wording describes each type. The result label is the script’s own judgment.
For an assignment the script counts only the ones giving access: any state except expired and deliveryFailed, including an assignment with no state (Microsoft Graph: accessPackageAssignment states (read 2026-11-04)). It works out the end from the schedule (Microsoft Graph: entitlementManagementSchedule (read 2026-11-04)) and judges the length from the start: no end date is NO END DATE, an end more than MAX_DAYS after the start is TOO LONG, and an end already past while the state is still not expired is PAST END DATE, NOT EXPIRED.
A policy that lets a requester propose their own timeline is not failed by that alone. Microsoft says allowCustomAssignmentSchedule set to false “indicates that the requestor isn’t permitted to include a schedule in their request” (Microsoft Graph: accessPackageAssignmentRequestorSettings (read 2026-11-04)). The script lists the policies where it is true for review, and the assignment check shows what end dates those requests really got.
Step 3. Classify every policy and assignment in one query
The judgment is two CASE expressions in DuckDB, copied from the script. The order of the lines is the order of precedence.
SELECT *, CASE
WHEN etype IN ('noExpiration','notSpecified') THEN 'NO END DATE'
WHEN etype = 'afterDuration' AND dur_days IS NOT NULL AND dur_days > $2 THEN 'TOO LONG'
WHEN etype = 'afterDuration' AND dur_days IS NOT NULL THEN 'OK'
WHEN etype = 'afterDateTime' AND end_dt IS NOT NULL AND end_dt <= $1 THEN 'END DATE PASSED'
WHEN etype = 'afterDateTime' AND end_dt IS NOT NULL AND end_dt > $1 + to_days(CAST($2 AS INTEGER)) THEN 'TOO LONG'
WHEN etype = 'afterDateTime' AND end_dt IS NOT NULL THEN 'OK'
ELSE 'EXPIRATION NOT READ' END AS status FROM pol
Assignments
SELECT *, CASE
WHEN etype IN ('noExpiration','notSpecified') THEN 'NO END DATE'
WHEN ends_at IS NULL OR start_dt IS NULL THEN 'EXPIRATION NOT READ'
WHEN ends_at <= $1 THEN 'PAST END DATE, NOT EXPIRED'
WHEN date_diff('second', start_dt, ends_at) > $2 * 86400 THEN 'TOO LONG'
ELSE 'OK' END AS status FROM asg_result WHERE in_scope
Step 4. Refuse to say PASS on an input that may be incomplete
Before it judges anything, the script runs four input checks. If any fails, the result is INCOMPLETE, never PASS. Each one has a fault planted in Run 1 that must trip it.
| Check | What it catches | Planted fault that trips it |
|---|---|---|
| schema | A property name the script reads that is not in Microsoft’s published Graph metadata, pinned to a commit and to a byte size and SHA-256 (the file, msgraph-metadata, MIT license (read 2026-11-04)). | A made-up property, expiresOn, added to the list the script reads |
| paging | A file whose answer carries an @odata.nextLink: part of the list is on another page. | A next link added to the policies file |
| ids | A policy with no id, or an id listed twice, which would count a policy twice. | The first policy listed a second time |
| references | An assignment that names no policy, or one that is not in the policies file, so its policy was not checked. Expired assignments and ones that failed to deliver are left out, as they are from the assignment check in Step 3. | An assignment naming a policy id that is not in the file |
In a planted run the script also checks itself: it stops with “PLANTED FAULT NOT REPORTED” if any planted fault does not show up in the output, and it fails if a planted record could not be planted.
Run 1: plant faults and see whether the script notices
We ran the script on 2026-11-04 with --plant, which adds faults to the generated records. It plants five policies (one with no end date, one of 365 days, one ending 2027-09-30, one with a duration in months, one that lets requesters propose a timeline), four assignments (one of them names a policy that is not in the file), and the other three input faults in Step 4: a made-up property, a next link and a repeated policy. Each row below says which line of the output reports it.
python3 guides/check-every-entra-access-package-has-an-end-date/entra_end_date_check.py --plant
Output from our run 1
check every access package policy ends access within 90 days; as of 2026-11-04 source generated demo records (seed 132), not from any tenant records 14 policies, 44 assignments schema cleanMetadataWithDescriptionsv1.0.xml at commit c4b98b7b, bytes=8200681 sha256=ff4f707d562dd1aa... == Input checks [FAIL] schema: not in the pinned Graph metadata: accessPackageAssignmentPolicy.expiresOn [FAIL] paging: 1 file(s) carry an @odata.nextLink, so the list continues on another page; join all pages into one file and delete its @odata.nextLink [FAIL] ids: 0 policies with no id and 1 ids listed more than once (a repeated policy is counted twice) [FAIL] references: 1 assignments name no policy, or one that is not in the policies file, so their policy was not checked == Policies: does each one end access within 90 days? OK 10 EXPIRATION NOT READ 1 NO END DATE 1 TOO LONG 2 - row 9 NO END DATE Finance close: read (quarter-end) (22f2846e): type noExpiration - row 10 TOO LONG Engineering logs: read (6d7fb6be): duration 365 days - row 11 TOO LONG Partner portal tables (f4f3f9c2): ends 2027-09-30 - row 12 EXPIRATION NOT READ Legal matters: read (bde304ed): duration missing or not in weeks, days, hours, minutes or seconds requesters may propose their own timeline on 1 policies; setting not in the file for 0 - row 13 REVIEW Research sandbox (ff252dc5): look at the end dates its assignments really got == Assignments: does each one that is giving access end within 90 days of starting? 12 assignments are expired or failed to deliver and are not checked OK 29 NO END DATE 1 PAST END DATE, NOT EXPIRED 1 TOO LONG 1 - row 41 NO END DATE Finance reporting (assignment c93d04ba, policy c6ca90c2) - row 42 TOO LONG Finance reporting (assignment bd4f26c5, policy c6ca90c2), 200 days - row 43 PAST END DATE, NOT EXPIRED Finance reporting (assignment f808ab74, policy c6ca90c2), 26 days PLANTED: 5 policies, 4 assignments and 4 input faults; every one was reported RESULT: INCOMPLETE (input checks failed: schema, paging, ids, references; 4 policies and 3 assignments to fix as well)
| Planted fault | Reported as |
|---|---|
Policy with type noExpiration | NO END DATE, row 9 |
| Policy with a duration of 365 days | TOO LONG, row 10 |
| Policy ending 2027-09-30, farther than the maximum from the run date | TOO LONG, row 11 |
| Policy with a duration in months | EXPIRATION NOT READ, row 12 |
| Policy that lets requesters propose a timeline | REVIEW, row 13 |
| Assignment under a good policy with no end date | NO END DATE, row 41 |
| Assignment that runs 200 days | TOO LONG, row 42 |
| Assignment past its end date, still delivered | PAST END DATE, NOT EXPIRED, row 43 |
| Made-up property, next link, repeated policy, unknown policy id | Four [FAIL] input checks, so RESULT: INCOMPLETE |
Two things to read in it. The three assignment faults sit under a policy that is itself OK: checking policies alone would have passed them. And the result is INCOMPLETE rather than FAIL because the input checks failed; the findings are still listed and counted in the last line.
Run 2: the clean records
The same script with no planted fault, run the same day. Every input check passes, every policy and every in-scope assignment has an end date within the maximum, and the result is PASS. PASS describes these files, not whether access ended. A policy listed for REVIEW does not change the result, so read those lines even on a PASS.
Command: run 2python3 guides/check-every-entra-access-package-has-an-end-date/entra_end_date_check.py
Output from our run 2
check every access package policy ends access within 90 days; as of 2026-11-04 source generated demo records (seed 132), not from any tenant records 8 policies, 40 assignments schema cleanMetadataWithDescriptionsv1.0.xml at commit c4b98b7b, bytes=8200681 sha256=ff4f707d562dd1aa... == Input checks [ok] schema: all 15 properties and 9 enumeration values the script reads exist in the pinned metadata [ok] paging: every file is one whole answer [ok] ids: every policy has its own id [ok] references: every assignment names a policy that is in the policies file == Policies: does each one end access within 90 days? OK 8 requesters may propose their own timeline on 0 policies; setting not in the file for 0 == Assignments: does each one that is giving access end within 90 days of starting? 12 assignments are expired or failed to deliver and are not checked OK 28 RESULT: PASS
The twelve assignments that are expired or failed to deliver are counted and left out, because they are not giving access. The output prints that count, so a reader can see how many rows were set aside. The script exits 0 by default; add --strict and it exits 1 unless the result is PASS, so a pipeline can stop on a missing end date.
Run it on your own tenant
To run it on your own tenant, save the answer of the assignment policies call as one JSON file, and, if you want assignments checked too, the answer of the assignments call (with $expand=accessPackage,assignmentPolicy) as another. If an answer ends with an @odata.nextLink, request that link as well and put the value lists of all the pages into the same file, then delete the @odata.nextLink from that file. In the download, change four lines near the top and nothing else: set POLICIES_FILE to the path of the policies file, ASSIGNMENTS_FILE to the path of the assignments file (leave it None to skip assignments), MAX_DAYS to the longest access you allow, in days, and AS_OF to the date of the day you run it, as YYYY-MM-DD.
Nothing else changes. The script prints policy names, access package names and the first eight characters of an id, never a person. A result of INCOMPLETE is not a pass: read which input check failed and fix the file. We also ran these steps on a made-up tenant file with different file names, a differently cased expiration type, a timestamp with seven decimals and an assignment that names no policy. The script read the file as intended and reported the missing policy as INCOMPLETE. The script still needs network access to fetch the metadata.
How to read the results and what a person does
| Result | What it means | What a person does |
|---|---|---|
| NO END DATE | Nothing sets an end date for this policy or assignment. | An access package manager sets an expiration on the policy, and decides what to do with the assignments already granted. |
| TOO LONG | The end is farther away than MAX_DAYS. | Shorten the policy, or record why this access needs longer. |
| END DATE PASSED | A policy’s fixed end date is on or before AS_OF. | Set a new date, or retire the policy. |
| PAST END DATE, NOT EXPIRED | An assignment’s end has passed but its state still says it is giving access. | Look at that assignment in Entra before trusting the end date. |
| EXPIRATION NOT READ | The file lacks the expiration, or holds a duration the script does not read. | Fix the export, or convert the months to days. |
| REVIEW | Requesters may propose their own timeline on this policy. | Look at the end dates its assignments really got, in the assignment table. |
| INCOMPLETE | An input check failed, so the lists may not be whole. | Fix the file the check names, then run again. |
Method
- Source of the records: generated in the script with a fixed seed (132), shaped as Microsoft Graph v1.0 returns an assignment policy and an assignment. They are not from any tenant. A reader’s files replace them.
- Property names: every property the script reads is checked against Microsoft’s Graph metadata for v1.0 at a pinned commit, file size 8,200,681 bytes, with its SHA-256 printed in the run. The check also confirms every enumeration value the script treats one by one.
- Judgment: the two
CASEexpressions in Step 3, written out so a reader can disagree with them.MAX_DAYSandAS_OFare settings, not Microsoft rules. - Dates: only timestamps written as full ISO 8601 with a time and a zone are read; anything else counts as not read, never guessed. Durations are read in weeks, days, hours, minutes and seconds.
- Order: the detail lines are printed in file order, so a re-run on the same files prints the same lines.
- Self-checks, each with the planted fault that trips it: the four input checks in Step 4, and, in a planted run, that each planted record is reported with the status planted for it.
Limits
- This is not a client result and not a run on a tenant. The records are generated. We shaped them as Microsoft’s documentation describes Graph’s answers, and we have not run the script against a live Entra tenant, so check the first run on your own files by reading the counts.
- An end date in Entra is not removal from your data platforms. The script reads Entra’s own settings. It does not check that a group, a role or a grant in a warehouse really ends on that date.
- Extensions are not checked. The lifecycle tab can let users extend their access (Microsoft Learn (read 2026-11-04)). The script reads no extension setting, and we did not test whether an extension changes the schedule Graph returns for an assignment.
- Requester-chosen timelines are listed, not judged. We found no statement in Microsoft’s documentation of what limit applies to an end date a requester proposes, so the script flags the policy and leaves the question to the assignment check.
- A moment, not a history. The files are what Graph returned when you saved them. Run it again after changes.
- Names are printed. Policy and access package names appear in the output. If those names are sensitive in your organization, treat the output accordingly.
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, fixed seed, shaped like Microsoft Graph v1.0 answers; not from any tenant. Property names checked against the Graph v1.0 metadata at a pinned commit
- Code
- entra_end_date_check.py
When this is not enough
- You need to know access really ended. Compare the end dates with what your data platforms show for the same people. This script stops at Entra’s settings.
- You need the approval trail. An end date says when access stops, not who agreed to it or why. That needs the request history, which is a separate record.
- You have more than one tenant, or many policies and assignments. Page through Graph, join the pages into one file per list, and run it once per tenant.
- Finding a gap is not fixing it. Someone with the right Entra role still has to change the policy and decide what happens to access already granted.
Want access that ends on its own, without running a check?
This script is a check you run yourself. It is not part of a Data Meaning product, and it changes nothing in Entra.
Data Access Concierge is a separate service, not new software. We set it up in your own systems and run it for you. On Microsoft Entra, each request form (an access package) has an owner who approves, a required reason and an end date, and every permission ends on that date without anyone acting.