The Lazy Way to Bulk Access Packages

By Sandra Saluti Sep 14, 2026

Clicking through a couple hundred users in the Entra admin portal, one at a time, takes exactly as long as you would expect. Too long.

A new system or process goes live, and suddenly every employee it applies to needs the matching Access Package. Or a project ends and that access needs to be removed again. Add and remove are simply two sides of the same governance lifecycle, but neither becomes particularly enjoyable when the number of users starts climbing.

My first instinct was the obvious one: put the users in Excel and let PowerShell work through the list.

Then I started wondering why I needed Excel at all.

If I can describe the population in Microsoft Graph with a $filter, why not make the filter the input instead? And if I already have a list of UPNs, why should I have to turn that into an Excel file just because the script expects one?

That was more or less how EntraAccessPackageToolkit.ps1 started taking shape.

One entry point, three ways in

The toolkit now has one public function, Invoke-AccessPackageAssignment, and three ways of telling it who to work with.

You can give it an Excel file, provide a Microsoft Graph $filter, or simply pass one or more UPNs directly.

Invoke-AccessPackageAssignment `
    -AccessPackageId $apId `
    -AssignmentPolicyId $policyId `
    -UserPrincipalName "anna@contoso.com" `
    -AdminAdd

Or, if the population is something Graph can describe, I can let Graph find the users.

Invoke-AccessPackageAssignment `
    -AccessPackageId $apId `
    -AssignmentPolicyId $policyId `
    -Filter "userType eq 'Member' and employeeType eq 'employee'" `
    -AdminAdd

The filter approach does come with an obvious caveat. It assumes that the attributes used to define the population are actually maintained properly. If the business has a handful of exceptions that do not fit the filter, an Excel list may still be the better choice. There is nothing particularly clever about pretending your directory data is cleaner than it is.

For advanced queries, Graph may also require the ConsistencyLevel: eventual header, which is why the toolkit handles that as part of the filter query rather than making the person running the script worry about it.

Then Graph started throttling me

The first version of the script submitted one assignment request per user.

That worked perfectly well until I tried it with a few hundred users.

Then Graph started returning 429 Too Many Requests.

Which is a fairly effective way of discovering that “just put it in a loop” is not quite a scaling strategy.

The current version uses Microsoft Graph JSON batching. Requests are grouped into batches of up to 20, including the UPN lookups used to resolve users to Entra object IDs.

Putting 20 requests inside one $batch request does not magically make those requests immune to throttling. Graph still evaluates the individual operations, and some of them can come back with a 429.

So the toolkit does not retry the entire batch blindly. It keeps the successful responses and retries only the throttled requests. If Graph provides a Retry-After value, that is used. Otherwise the script falls back to exponential backoff.

Add and remove are not actually symmetrical

At first glance, adding and removing an Access Package assignment should be the same operation in reverse. The API disagrees.

For adminAdd, I deliberately do not check whether the user already has the assignment before submitting the request.

Why make another Graph call for every user, or retrieve all existing assignments first, just to avoid sending a request that Graph already knows how to reject?

If the user already has the assignment, Graph tells me. The response comes back with InvalidRequestExistingGrant, and the toolkit turns that into AlreadyAssigned.

A pending request is different again. Graph returns ExistingOpenRequest, and the toolkit reports that as OpenRequestExists.

Remove is different.

For adminRemove, Graph requires the existing assignment ID. I cannot simply say “remove this user from this Access Package” in the same way I can say “assign this user to this Access Package”.

So before an adminRemove, the toolkit retrieves the current assignments for the Access Package, filters them to assignments in the Delivered state, expands the target and maps the user’s object ID to the assignment ID.

That is not an optional duplicate check. It is required because that is how the API identifies the assignment that should be removed.

If there is no current assignment, there is nothing to submit. The user is reported as NotAssigned instead.

I wanted the result, not just a successful request

A successful HTTP request is not necessarily the same thing as a useful operational result.

If I run this against 300 users, I want to know what happened to those 300 users. Not just that the script completed without crashing.

There is also an important distinction in what Graph returns. A request can come back as Submitted with a status of Accepted, but that does not mean the assignment has already reached Delivered. The request still has to move through the Entitlement Management process.

I deliberately don’t wait for that to happen. The time it takes for an assignment to move through the different stages can vary significantly, so waiting for every user to reach Delivered would add an unpredictable delay to a bulk operation. Once the request is Submitted and Accepted, my job is done. Microsoft Entra Governance takes over the rest of the process, and the progress can be followed in the Entitlement Management audit logs.

The toolkit keeps a result for every user. A user can be reported as

  • Submitted
  • AlreadyAssigned
  • NotAssigned
  • OpenRequestExists
  • Failed

Users that could not be resolved from their UPN are kept in the result as well rather than silently disappearing.

The same result is then used for the console output, the Excel report and the final summary. Failures are also grouped by their actual error message, so I can see whether I have one problem affecting many users or several different problems.

A report that tells me “17 failed” is technically a report.

It just isn’t a particularly useful one.

A little paranoia is useful when you are about to change 500 users

Before anything is submitted, it validates the Access Package and Assignment Policy, resolves the target population and shows a preview of the first ten users. There is also a MaxUsers safety limit, which defaults to 500. If the population exceeds that limit, the operation stops before making changes.

PreviewOnly lets me resolve the population without submitting anything.

The script can also attempt to bypass the approval step when the assignment policy allows it, with a justification included in the request. I deliberately say “attempt” here because the policy still controls whether that is actually allowed.

For smaller operations, I can also use -SkipReport. If I am adding one user, I don’t particularly need a folder picker and an Excel file containing one row. The same function still works, but I can just take the result and move on.

And then there is the least sophisticated safety mechanism of all:

Type 'yes' to confirm

Sometimes a confirmation prompt is still a very good feature, especially when the filter you just wrote can match considerably more people than you had in mind.

So why not just use the Microsoft Graph PowerShell cmdlet?

Microsoft already gives us New-MgEntitlementManagementAssignmentRequest, and it does exactly what it is supposed to do: create the Access Package assignment request.

My problem was everything I had to do before and after that request. I wanted to resolve the users, batch the requests, handle throttling and turn Graph’s responses into something useful afterwards.

That is where the toolkit comes in. Microsoft gives me the API operation. The toolkit gives me the workflow around it.

If you want to try it yourself, the script is available in the Access Packages folder of my EntraGovernance-Scripts repository.

The lazy way

None of this is particularly exotic engineering.

It started with not wanting to click through hundreds of users. Excel seemed like the obvious solution, until I wondered why I needed Excel at all. One problem led to another, and the simple script gradually turned into something considerably more useful.

That is probably how most internal tooling gets built. You solve one annoying problem, discover the next one, fix that, and eventually end up with something that saves you from having the same annoying afternoon again.

I still think the original idea was valid.

I just prefer my laziness with a little governance around it.

Author

  • I work with Microsoft's identity solutions at Epical, with a particular focus on Microsoft Entra ID Governance, identity lifecycle automation and PowerShell. I write about practical challenges, things I've learned, and solutions I've built along the way.

    Based in Sweden. Find me on LinkedIn and GitHub.

    View all posts

Discover more from Agder in the cloud

Subscribe to get the latest posts sent to your email.

By Sandra Saluti

I work with Microsoft's identity solutions at Epical, with a particular focus on Microsoft Entra ID Governance, identity lifecycle automation and PowerShell. I write about practical challenges, things I've learned, and solutions I've built along the way.

Based in Sweden. Find me on LinkedIn and GitHub.

Leave a Reply