Private/Invoke-AccessPackageResourceChange.ps1

function Invoke-AccessPackageResourceChange {
    <#
    .SYNOPSIS
        Shared engine behind Add-SiSAccessPackageResource and
        Remove-SiSAccessPackageResource: reads every package first, shows one
        preview, confirms once, then changes.
    .NOTES
        Internal helper.
    #>


    [OutputType('SiSGovernance.ResourceChangeResult')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet('Add', 'Remove')]
        [string]$Action,

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

        [Parameter(Mandatory = $true)]
        [object]$Spec
    )

    $baseUri = "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement"
    $operation = $Action.ToUpper()

    Write-Host "==========================================" -ForegroundColor DarkGray
    Write-Host " $operation ACCESS PACKAGE RESOURCE " -ForegroundColor Cyan
    Write-Host "==========================================" -ForegroundColor DarkGray

    if (-not (Confirm-GraphConnection -Scopes 'EntitlementManagement.ReadWrite.All')) { return }

    $results = [System.Collections.Generic.List[object]]::new()
    $newResult = {
        param($package, [string]$status, [string]$message, [string]$resourceName, [string]$roleName)
        [PSCustomObject]@{
            PSTypeName        = 'SiSGovernance.ResourceChangeResult'
            AccessPackageId   = $package.Id
            AccessPackageName = $package.Name
            ResourceType      = $Spec.Type
            Resource          = if ($resourceName) { $resourceName } else { $Spec.OriginId }
            Role              = if ($roleName) { $roleName } else { $Spec.RoleName }
            Status            = $status
            Error             = $message
        }
    }

    # --- PLAN: read everything first, change nothing ----------------------------
    Write-Host "`n--- ACCESS PACKAGES ---" -ForegroundColor Cyan
    $plans = [System.Collections.Generic.List[object]]::new()
    $catalogLookups = @{}

    foreach ($id in $AccessPackageId) {
        $state = Get-AccessPackageResourceState -AccessPackageId $id
        if ($state.Error) {
            $results.Add((& $newResult ([PSCustomObject]@{ Id = $id; Name = $null }) 'Failed' $state.Error))
            Write-Host " Not found: $id" -ForegroundColor Red
            continue
        }
        $package = [PSCustomObject]@{ Id = $id; Name = $state.Package['displayName'] }
        Write-Host " $($package.Name) (catalog: $($state.CatalogName))" -ForegroundColor Green

        # One catalog lookup per catalog, even if many packages are piped in
        if (-not $catalogLookups.ContainsKey($state.CatalogId)) {
            $catalogLookups[$state.CatalogId] = Get-CatalogResource -CatalogId $state.CatalogId -Spec $Spec
        }
        $lookup = $catalogLookups[$state.CatalogId]

        # What the package already grants for THIS resource. Role originId alone
        # isn't unique (SharePoint roles are '3', '4'...), so match the scope too.
        $existing = @($state.ResourceRoleScopes | Where-Object {
                $_.scope.originId -eq $Spec.OriginId -and
                ((-not $Spec.RoleOriginId -and ($_.role.displayName -eq $Spec.RoleName -or $_.role.originId -eq $Spec.RoleName)) -or
                ($Spec.RoleOriginId -and $_.role.originId -eq $Spec.RoleOriginId))
            })

        $plan = [PSCustomObject]@{
            Package     = $package
            CatalogId   = $state.CatalogId
            CatalogName = $state.CatalogName
            Existing    = $existing
            Lookup      = $lookup
            Action      = $null
            Message     = $null
        }

        if ($Action -eq 'Add') {
            if ($existing.Count -gt 0) {
                $plan.Action = 'AlreadyAdded'
            }
            elseif (-not $lookup.Resource) {
                # Role can only be checked once the resource is in the catalog
                $plan.Action = 'AddToCatalogAndPackage'
            }
            else {
                $roleCheck = Find-AccessPackageResourceRole -Spec $Spec -Roles $lookup.Roles
                if ($roleCheck.Error) {
                    $plan.Action = 'Invalid'
                    $plan.Message = $roleCheck.Error
                }
                else {
                    $plan.Action = 'Add'
                }
            }
        }
        else {
            $plan.Action = if ($existing.Count -gt 0) { 'Remove' } else { 'NotOnPackage' }
        }
        $plans.Add($plan)
    }

    foreach ($p in ($plans | Where-Object { $_.Action -in 'AlreadyAdded', 'NotOnPackage', 'Invalid' })) {
        $status = switch ($p.Action) { 'AlreadyAdded' { 'AlreadyAdded' } 'NotOnPackage' { 'NotOnPackage' } default { 'Failed' } }
        $results.Add((& $newResult $p.Package $status $p.Message))
    }
    $toChange = @($plans | Where-Object { $_.Action -in 'Add', 'AddToCatalogAndPackage', 'Remove' })

    if ($toChange.Count -gt 0) {
        Write-Host "`n $Action on $($toChange.Count) access package(s):" -ForegroundColor Yellow
        Write-Host " Resource : $($Spec.Type) - $($Spec.OriginId)" -ForegroundColor White
        Write-Host " Role : $($Spec.RoleName)" -ForegroundColor White
        foreach ($p in $toChange) {
            $note = if ($p.Action -eq 'AddToCatalogAndPackage') { ' (resource is added to the catalog first)' } else { '' }
            Write-Host " - $($p.Package.Name)$note" -ForegroundColor White
        }
        if ($Action -eq 'Add') {
            Write-Host " Everyone already assigned to these packages gets this role." -ForegroundColor Yellow
        }
        else {
            Write-Host " Everyone assigned to these packages loses this role." -ForegroundColor Yellow
        }

        # One confirmation for all packages, also when many are piped in
        $target = "$($Spec.Type) $($Spec.OriginId) ($($Spec.RoleName)) on $($toChange.Count) access package(s)"
        if (-not $PSCmdlet.ShouldProcess($target, "$Action resource role")) {
            if (-not $WhatIfPreference) { Write-Warning "Cancelled - no changes were made." }
            foreach ($p in $toChange) {
                $rowStatus = if ($WhatIfPreference) { "Preview - $($p.Action)" } else { 'Cancelled' }
                $results.Add((& $newResult $p.Package $rowStatus $null))
            }
            $toChange = @()
        }
    }

    # --- CHANGE --------------------------------------------------------------------
    if ($toChange.Count -gt 0) {
        Write-Host "`n--- $operation RESOURCE ---" -ForegroundColor Cyan
    }
    foreach ($p in $toChange) {
        if ($Action -eq 'Remove') {
            $ok = $true
            foreach ($rrs in $p.Existing) {
                try {
                    Invoke-MgGraphRequest -Method DELETE -Uri "$baseUri/accessPackages/$($p.Package.Id)/resourceRoleScopes/$($rrs.id)" -ErrorAction Stop | Out-Null
                }
                catch {
                    $ok = $false
                    $results.Add((& $newResult $p.Package 'Failed' $_.Exception.Message $rrs.role.resource.displayName $rrs.role.displayName))
                    Write-Host " Failed: $($p.Package.Name) - $($_.Exception.Message)" -ForegroundColor Red
                }
            }
            if ($ok) {
                $results.Add((& $newResult $p.Package 'Removed' $null $p.Existing[0].role.resource.displayName $p.Existing[0].role.displayName))
                Write-Host " Removed from: $($p.Package.Name)" -ForegroundColor Green
            }
            continue
        }

        # Add: make sure the resource is in this package's catalog (once per catalog)
        $lookup = $p.Lookup
        if (-not $lookup.Resource) {
            $lookup = Get-CatalogResource -CatalogId $p.CatalogId -Spec $Spec -AddIfMissing
            $catalogLookups[$p.CatalogId] = $lookup
            foreach ($other in $toChange) { if ($other.CatalogId -eq $p.CatalogId) { $other.Lookup = $lookup } }
            if ($lookup.Error) {
                $results.Add((& $newResult $p.Package 'Failed' $lookup.Error))
                Write-Host " Failed: $($p.Package.Name) - $($lookup.Error)" -ForegroundColor Red
                continue
            }
            if ($lookup.AddedToCatalog) {
                Write-Host " Added to catalog '$($p.CatalogName)': $($lookup.Resource.displayName)" -ForegroundColor Green
            }
        }

        $roleCheck = Find-AccessPackageResourceRole -Spec $Spec -Roles $lookup.Roles
        if ($roleCheck.Error) {
            $results.Add((& $newResult $p.Package 'Failed' $roleCheck.Error $lookup.Resource.displayName))
            Write-Host " Failed: $($p.Package.Name) - $($roleCheck.Error)" -ForegroundColor Red
            continue
        }
        $role = $roleCheck.Role
        $scope = @($lookup.Resource.scopes | Where-Object { $_.isRootScope -eq $true }) + @($lookup.Resource.scopes) | Select-Object -First 1

        $roleBody = @{
            originId     = $role.originId
            displayName  = $role.displayName
            originSystem = $Spec.OriginSystem
            resource     = @{
                id           = $lookup.Resource.id
                originId     = $lookup.Resource.originId
                originSystem = $Spec.OriginSystem
            }
        }
        # Group roles come back with an all-zero id - Graph wants no id then
        if ($role.id -and $role.id -ne '00000000-0000-0000-0000-000000000000') { $roleBody.id = $role.id }

        $scopeBody = @{
            originId     = if ($scope) { $scope.originId } else { $lookup.Resource.originId }
            originSystem = $Spec.OriginSystem
            isRootScope  = $true
        }
        if ($scope -and $scope.id) { $scopeBody.id = $scope.id }

        $body = @{ role = $roleBody; scope = $scopeBody } | ConvertTo-Json -Depth 6
        try {
            Invoke-MgGraphRequest -Method POST -Uri "$baseUri/accessPackages/$($p.Package.Id)/resourceRoleScopes" `
                -Body $body -ContentType 'application/json' -ErrorAction Stop | Out-Null
            $results.Add((& $newResult $p.Package 'Added' $null $lookup.Resource.displayName $role.displayName))
            Write-Host " Added to: $($p.Package.Name)" -ForegroundColor Green
        }
        catch {
            $results.Add((& $newResult $p.Package 'Failed' $_.Exception.Message $lookup.Resource.displayName $role.displayName))
            Write-Host " Failed: $($p.Package.Name) - $($_.Exception.Message)" -ForegroundColor Red
        }
    }

    Write-Host "`n==========================================" -ForegroundColor DarkGray
    Write-Host " OPERATION COMPLETE" -ForegroundColor Cyan
    Write-Host "==========================================" -ForegroundColor DarkGray
    foreach ($s in ($results | Group-Object Status | Sort-Object Count -Descending)) {
        Write-Host " $($s.Name): $($s.Count)" -ForegroundColor White
    }

    # One object per package, so the output can be piped on (Format-Table, Export-Excel...)
    $results.ToArray()
}