Private/Invoke-AccessPackageBatchOperation.ps1

function Invoke-AccessPackageBatchOperation {
    <#
    .SYNOPSIS
        Submits adminAdd/adminRemove requests for a list of already-resolved
        users, in batches of up to $BatchSize. adminAdd has no pre-check - every
        user is submitted and the resulting status is read entirely from Graph's
        own response. adminRemove first resolves each user's existing assignment
        id via Resolve-AccessPackageAssignmentIds, since Graph's adminRemove needs
        that id and has no other way to identify the assignment to remove; users
        with nothing to remove are reported as NotAssigned without ever being
        submitted.
    .OUTPUTS
        Array of PSCustomObject: UserPrincipalName, DisplayName, ObjectId,
        Status (Submitted / AlreadyAssigned / NotAssigned / OpenRequestExists / Failed), Error.
    .NOTES
        Status meanings for the non-Submitted cases
        Graph responses:
          AlreadyAssigned - adminAdd, target already has the assignment (409 InvalidRequestExistingGrant)
          NotAssigned - adminRemove, target has no active assignment to remove (404 InvalidRequestNoActiveGrant)
          OpenRequestExists - a request (add or remove) for this target/access package is already pending (400 InvalidRequest / details ExistingOpenRequest)
    #>


    # One list on purpose (the caller gets it in one piece, also with 1 item)
    [OutputType([object[]])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$AccessPackageId,

        [Parameter(Mandatory = $true)]
        [string]$AssignmentPolicyId,

        [Parameter(Mandatory = $true)]
        [array]$UserList,

        [Parameter(Mandatory = $true)]
        [ValidateSet("adminAdd", "adminRemove")]
        [string]$RequestType,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 20)]
        [int]$BatchSize = 20,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 60000)]
        [int]$DelayMs = 1000
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $toSubmit = [System.Collections.Generic.List[object]]::new()

    if ($RequestType -eq 'adminRemove') {
        # adminRemove identifies the assignment to remove purely by its own
        # id (assignment.id) - Graph has no other way to reference it, so
        # this lookup is required, not an optional pre-check.
        Write-Host "`n Looking up existing assignments to resolve removal targets..." -ForegroundColor Cyan
        $lookupResult = Resolve-AccessPackageAssignmentIds -AccessPackageId $AccessPackageId -UserList $UserList
        if (-not $lookupResult) {
            # Without the lookup nobody can be removed - and nobody should be
            # reported as NotAssigned either, since that isn't known
            foreach ($user in $UserList) {
                $results.Add([PSCustomObject]@{
                        UserPrincipalName = $user.userPrincipalName
                        DisplayName       = $user.displayName
                        ObjectId          = $user.id
                        Status            = 'Failed'
                        Error             = 'Could not look up existing assignments'
                    })
            }
            return ,@($results)
        }

        foreach ($user in $lookupResult.NotFound) {
            Write-Host " Not assigned (nothing to remove): $($user.userPrincipalName)" -ForegroundColor Yellow
            $results.Add([PSCustomObject]@{
                    UserPrincipalName = $user.userPrincipalName
                    DisplayName       = $user.displayName
                    ObjectId          = $user.id
                    Status            = 'NotAssigned'
                    Error             = $null
                })
        }

        foreach ($entry in $lookupResult.Resolved) {
            $body = @{
                requestType = 'adminRemove'
                assignment  = @{ id = $entry.AssignmentId }
            }
            $toSubmit.Add(@{
                    CorrelationKey = $entry.User
                    Method         = 'POST'
                    Url            = '/identityGovernance/entitlementManagement/assignmentRequests'
                    Body           = $body
                })
        }
    }
    else {

        foreach ($user in $UserList) {
            $body = @{
                requestType = $RequestType
                assignment  = @{
                    accessPackageId    = $AccessPackageId
                    assignmentPolicyId = $AssignmentPolicyId
                    target              = @{ objectId = $user.id }
                }
            }

            $toSubmit.Add(@{
                    CorrelationKey = $user
                    Method         = 'POST'
                    Url            = '/identityGovernance/entitlementManagement/assignmentRequests'
                    Body           = $body
                })
        }
    }

    Write-Host "`n Submitting $RequestType for $($toSubmit.Count) user(s) via batch..." -ForegroundColor Cyan

    if ($toSubmit.Count -gt 0) {
        $responses = Invoke-GraphBatch -Requests @($toSubmit) -BatchSize $BatchSize -DelayMs $DelayMs

        foreach ($r in $responses) {
            $user = $r.CorrelationKey
            if ($r.Status -in 200, 201, 202) {
                Write-Host " $RequestType submitted: $($user.userPrincipalName)" -ForegroundColor Green
                $results.Add([PSCustomObject]@{
                        UserPrincipalName = $user.userPrincipalName
                        DisplayName       = $user.displayName
                        ObjectId          = $user.id
                        Status            = 'Submitted'
                        Error             = $null
                    })
            }
            else {
                $errorCode    = $r.Body.error.code
                $errorMessage = $r.Body.error.message
                $detailCodes  = @($r.Body.error.details | ForEach-Object { $_.code })

                if ($errorCode -eq 'InvalidRequestExistingGrant') {
                    Write-Host " Already assigned (caught at submit time): $($user.userPrincipalName)" -ForegroundColor Yellow
                    $status = 'AlreadyAssigned'
                }
                elseif ($errorCode -eq 'InvalidRequestNoActiveGrant') {
                    Write-Host " Not assigned (nothing to remove): $($user.userPrincipalName)" -ForegroundColor Yellow
                    $status = 'NotAssigned'
                }
                elseif ($detailCodes -contains 'ExistingOpenRequest') {
                    Write-Host " Open request already pending: $($user.userPrincipalName)" -ForegroundColor Yellow
                    $status = 'OpenRequestExists'
                }
                elseif ($errorMessage -match 'already') {
                    # Fallback for a wording/code variant we haven't seen confirmed yet.
                    Write-Host " Already assigned (caught at submit time): $($user.userPrincipalName)" -ForegroundColor Yellow
                    $status = 'AlreadyAssigned'
                }
                else {
                    Write-Host " Failed: $($user.userPrincipalName) - $errorMessage" -ForegroundColor Red
                    $status = 'Failed'
                }
                $results.Add([PSCustomObject]@{
                        UserPrincipalName = $user.userPrincipalName
                        DisplayName       = $user.displayName
                        ObjectId          = $user.id
                        Status            = $status
                        Error             = $errorMessage
                    })
            }
        }
    }

    return ,@($results)
}