Check recipe · Data Access Concierge

Check that every Microsoft Entra access package ends access on a set date

DuckDB2026-11-04Script downloadable

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)).

How each expiration is judged
Type in GraphWhat Microsoft saysResult 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.

Policies
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.

Input checks
CheckWhat it catchesPlanted fault that trips it
schemaA 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
pagingA file whose answer carries an @odata.nextLink: part of the list is on another page.A next link added to the policies file
idsA policy with no id, or an id listed twice, which would count a policy twice.The first policy listed a second time
referencesAn 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.

Command: run 1
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)
What run 1 reported
Planted faultReported as
Policy with type noExpirationNO END DATE, row 9
Policy with a duration of 365 daysTOO LONG, row 10
Policy ending 2027-09-30, farther than the maximum from the run dateTOO LONG, row 11
Policy with a duration in monthsEXPIRATION NOT READ, row 12
Policy that lets requesters propose a timelineREVIEW, row 13
Assignment under a good policy with no end dateNO END DATE, row 41
Assignment that runs 200 daysTOO LONG, row 42
Assignment past its end date, still deliveredPAST END DATE, NOT EXPIRED, row 43
Made-up property, next link, repeated policy, unknown policy idFour [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 2
python3 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

Results and next steps
ResultWhat it meansWhat a person does
NO END DATENothing 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 LONGThe end is farther away than MAX_DAYS.Shorten the policy, or record why this access needs longer.
END DATE PASSEDA policy’s fixed end date is on or before AS_OF.Set a new date, or retire the policy.
PAST END DATE, NOT EXPIREDAn 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 READThe file lacks the expiration, or holds a duration the script does not read.Fix the export, or convert the months to days.
REVIEWRequesters may propose their own timeline on this policy.Look at the end dates its assignments really got, in the assignment table.
INCOMPLETEAn 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 CASE expressions in Step 3, written out so a reader can disagree with them. MAX_DAYS and AS_OF are 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 duckdb module
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.

See how Data Access Concierge works