Public/ServiceEM/Remove-EntraOpsServiceCatalog.ps1

<#
.SYNOPSIS
    Removes an Entitlement Management catalog, all its contents, the associated
    Entra groups, and the Azure resource group created by the landing zone.

.DESCRIPTION
    Performs a complete teardown of a ServiceEM landing zone:
    1. Cancels open assignment requests of the access packages (e.g. pending approval) and waits for
       requests that can't be canceled (e.g. delivering), because Graph rejects a removal while a
       request is open.
    2. Revokes all active (non-expired) access package assignments via
       adminRemove requests and verifies successful delivery and the absence of active assignments.
    3. Deletes all access packages in the catalog, retrying only InvalidDeleteEntitlementHasGrants
       for up to 60 seconds if the grants index has not caught up.
       If a request doesn't complete, a removal or an access package deletion fails, the cmdlet stops
       with an error and keeps the catalog, the groups and the Azure resource group, so a re-run can
       finish the removal.
    4. Deletes the Entra groups registered as catalog resources that were created by the landing zone
       (mailNickname "<ServiceName>.*", ServiceName = catalog name without
       "Catalog-"). Other catalog resources, e.g. shared groups added manually, are kept, as well as any
       group whose Object ID appears in ExcludeGroupIds. A failed group deletion doesn't stop the removal
       of the other groups.
    5. Deletes the catalog itself, only if all groups were deleted; otherwise the catalog is kept so a
       re-run can find it and finish the removal.
    6. Only with -RemoveAzureResourceGroup: deletes the Azure resource group created
       for the service in -SubscriptionId if it exists: RG-<ServiceName> with a
       leading "Rg-" removed (e.g. Catalog-Rg-MyApp -> RG-MyApp), only if it has the tag
       EntraOpsServiceEM = <ServiceName> set by New-EntraOpsServiceAZContainer. Subscription scope
       catalogs (Catalog-Sub-*) never delete a resource group; their Azure role
       assignments on the subscription must be removed manually.
    Failures of steps 4 and 5 are collected and reported in one error at the end.

    Displays progress during teardown and prints a completion summary to the information stream.
    Set $ProgressPreference = 'SilentlyContinue' to hide progress, and pass -InformationAction Ignore
    to hide the summary. The pipeline return value remains an empty hashtable.

    Asks for confirmation unless -Force or -Confirm:$false is passed; supports -WhatIf.
    Intended for decommissioning services provisioned by New-EntraOpsServiceBootstrap.

.PARAMETER ServiceCatalogName
    Display name of the catalog to remove (e.g. "Catalog-MyService").

.PARAMETER Force
    Skips the confirmation prompt. Without -Force (and without -Confirm:$false) the cmdlet asks for
    confirmation before deleting anything. Use -WhatIf to show what would be deleted.

.PARAMETER ExcludeGroupIds
    Object IDs of Entra groups that must not be deleted even if they are
    registered as catalog resources. Use this to protect shared delegation
    groups (ControlPlane-Admins, ManagementPlane-Admins, CatalogPlane-Members)
    that are reused across multiple services.

.PARAMETER RemoveAzureResourceGroup
    Opt-in: also deletes the Azure resource group of the service including all its resources.
    Requires -SubscriptionId. Without this switch the resource group is kept. A resource group without the tag
    EntraOpsServiceEM = <ServiceName> (e.g. an existing one reused by the landing zone) is always kept.

.PARAMETER SubscriptionId
    Subscription of the resource group deleted with -RemoveAzureResourceGroup. Required so that a
    resource group with the same name in the subscription of the current Azure context isn't deleted
    by mistake; the previous Azure context is restored afterwards.

.PARAMETER logPrefix
    Text prepended to verbose messages. Defaults to the function name.

.EXAMPLE
    Remove-EntraOpsServiceCatalog -ServiceCatalogName "Catalog-MyService" -Force

    Revokes all active assignments, deletes all access packages, removes the
    catalog and deletes all associated Entra groups. The Azure RG is kept.

.EXAMPLE
    Remove-EntraOpsServiceCatalog -ServiceCatalogName "Catalog-MyService" -Force `
        -ExcludeGroupIds @(
            "00000000-0000-0000-0000-000000000001", # ControlPlane-Admins (shared)
            "00000000-0000-0000-0000-000000000002" # ManagementPlane-Admins (shared)
        )

    Same as above but skips deletion of the two shared delegation groups.

.EXAMPLE
    Remove-EntraOpsServiceCatalog -ServiceCatalogName "Catalog-Rg-MyService" -Force -RemoveAzureResourceGroup `
        -SubscriptionId "<subscription-id>"

    Removes all Entra ID / EM artifacts and also deletes the Azure resource group RG-MyService.

.EXAMPLE
    Remove-EntraOpsServiceCatalog -ServiceCatalogName "Catalog-MyService" -WhatIf

    Shows the catalog, access packages and groups that would be deleted without changing anything.

#>

function Remove-EntraOpsServiceCatalog {
    [OutputType([hashtable])]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)]
        [string]$ServiceCatalogName,

        [switch]$Force,

        [string[]]$ExcludeGroupIds = @(),

        [switch]$RemoveAzureResourceGroup,

        [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
        [string]$SubscriptionId,

        [string]$logPrefix = "[$($MyInvocation.MyCommand)]"
    )

    begin {
        $progressActivity = $null
        $progressId = 47002
    }

    process {
        if ($RemoveAzureResourceGroup -and [string]::IsNullOrWhiteSpace($SubscriptionId)) {
            throw "-RemoveAzureResourceGroup requires -SubscriptionId (subscription of the landing zone resource group)."
        }
        if ($Force -and -not $WhatIfPreference) {
            $ConfirmPreference = 'None'
        }
        $encodedServiceCatalogName = ConvertTo-EntraOpsODataStringLiteral -Value $ServiceCatalogName
        $catalogs = @(Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/identityGovernance/entitlementManagement/catalogs?`$filter=displayName eq '$encodedServiceCatalogName'&`$expand=accessPackages,resources" -OutputType PSObject -DisableCache -ThrowOnFailure | Where-Object { $null -ne $_ })
        if ($catalogs.Count -eq 0) {
            Write-Warning "$logPrefix Unable to obtain catalog by name"
            return @{}
        }
        # Never enumerate packages or groups from an ambiguous name lookup during teardown.
        if ($catalogs.Count -gt 1) {
            throw "$logPrefix Found $($catalogs.Count) catalogs named '$ServiceCatalogName'; no resources were removed. Use a unique catalog name before retrying."
        }
        $catalog = $catalogs[0]
        if ([string]::IsNullOrWhiteSpace([string]$catalog.Id)) {
            throw "$logPrefix Catalog '$ServiceCatalogName' has no object ID; no resources were removed."
        }
        Write-Verbose "$logPrefix Obtained Service Catalog $($catalog.Id)"
        $groupResourceCount = @($catalog.Resources | Where-Object { $_.OriginSystem -eq "AadGroup" }).Count
        $target = "$ServiceCatalogName ($(@($catalog.accessPackages).Count) access packages, $groupResourceCount group resources$(if ($RemoveAzureResourceGroup) { ', Azure resource group' }))"
        if (-not $PSCmdlet.ShouldProcess($target, "Remove assignments, access packages, catalog and landing zone groups")) {
            return @{}
        }

        $progressActivity = "Removing ServiceEM: $ServiceCatalogName"
        Write-Progress -Id $progressId -Activity $progressActivity -Status 'Checking open access requests'
        $azureCleanupNeedsAttention = $false
        $azureSummary = 'Azure resource group kept'

        $terminalStates = @("fulfilled", "delivered", "canceled", "deliveryfailed", "denied", "completed", "partiallydelivered", "dropped")
        $cancellableStatuses = @("accepted", "pendingApproval", "pendingNotBefore", "pendingApprovalEscalated")
        $failures = [System.Collections.Generic.List[string]]::new()

        function Test-OpenRequest ($Request) {
            if ($null -eq $Request) { return $false }
            if (-not $Request.id) { throw "Unable to verify assignment request; the response has no request ID." }
            # State is authoritative; status supplies detail and is only a fallback for older responses.
            $state = if ($Request.state) { $Request.state } else { $Request.status }
            $terminalStates -notcontains $state
        }

        function Stop-IncompleteRemoval {
            if ($failures.Count -gt 0) {
                throw "$logPrefix Removal of '$ServiceCatalogName' is incomplete; the catalog, its groups and the Azure resource group were kept. Resolve the following and run the cmdlet again: $($failures -join '; ')"
            }
        }

        function Wait-OpenRequest ([string[]]$RequestIds, [string]$Activity) {
            $check = @{ Pending = @($RequestIds); Requests = @() }
            $null = Wait-EntraOpsServiceEMCondition -Activity $Activity -logPrefix $logPrefix -ProgressStatus { "$($check.Pending.Count) of $($RequestIds.Count) request(s) pending" } -Condition {
                $check.Requests = @($RequestIds | ForEach-Object {
                    $request = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/identityGovernance/entitlementManagement/assignmentRequests/$_" -OutputType PSObject -DisableCache -ThrowOnFailure
                    if (-not $request -or $request.id -ne $_) { throw "Unable to verify assignment request $_; no matching request was returned." }
                    $request
                })
                $check.Pending = @($check.Requests | Where-Object { Test-OpenRequest $_ })
                $details = ($check.Requests | ForEach-Object { "$($_.id) (state '$($_.state)', status '$($_.status)')" }) -join ', '
                Write-Verbose "$logPrefix ${Activity}: $($check.Pending.Count) pending of $($RequestIds.Count); $details"
                $check.Pending.Count -eq 0
            }
            return $check
        }

        # Step 1: Cancel open requests (e.g. pending approval) or wait for them; Graph rejects adminRemove while one is open.
        $openRequestIds = [System.Collections.Generic.List[string]]::new()
        foreach ($accessPackage in $catalog.accessPackages) {
            $requests = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/identityGovernance/entitlementManagement/assignmentRequests?`$expand=accessPackage&`$filter=accessPackage/id eq '$($accessPackage.Id)'" -OutputType PSObject -DisableCache -ThrowOnFailure
            foreach ($request in @($requests) | Where-Object { Test-OpenRequest $_ }) {
                if ($request.status -in $cancellableStatuses -or $request.state -in @("pendingApproval", "scheduled")) {
                    Write-Verbose "$logPrefix Canceling open $($request.requestType) request $($request.id) ($($request.status)) in $($accessPackage.displayName)"
                    try {
                        Invoke-EntraOpsMsGraphQuery -Method POST -Uri "/v1.0/identityGovernance/entitlementManagement/assignmentRequests/$($request.id)/cancel" -ThrowOnFailure | Out-Null
                    } catch {
                        Write-Warning "$logPrefix Failed to cancel request $($request.id) in $($accessPackage.displayName): $($_.Exception.Message)"
                    }
                } else {
                    Write-Verbose "$logPrefix Waiting for open $($request.requestType) request $($request.id) ($($request.status)) in $($accessPackage.displayName)"
                }
                $openRequestIds.Add($request.id)
            }
        }
        if ($openRequestIds.Count -gt 0) {
            $openResult = Wait-OpenRequest -RequestIds $openRequestIds -Activity "Open assignment requests"
            $stillOpen = @($openResult.Pending)
            if ($stillOpen.Count -gt 0) {
                $details = ($stillOpen | ForEach-Object { "$($_.id) (state '$($_.state)', status '$($_.status)')" }) -join ', '
                $failures.Add("$($stillOpen.Count) open assignment request(s) did not complete: $details")
            }
        }

        Stop-IncompleteRemoval

        Write-Progress -Id $progressId -Activity $progressActivity -Status 'Submitting assignment removals'

        # Step 2: Submit ALL adminRemove requests across all packages at once.
        $removeRequests = [System.Collections.Generic.List[object]]::new()
        foreach ($accessPackage in $catalog.accessPackages) {
            # Filtering by navigation path 'accessPackage/id' requires ConsistencyLevel:eventual + $count=true.
            $assignments = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/identityGovernance/entitlementManagement/assignments?`$count=true&`$filter=accessPackage/id eq '$($accessPackage.Id)'&`$expand=target" -OutputType PSObject -DisableCache -ConsistencyLevel "eventual" -ThrowOnFailure
            foreach ($assignment in @($assignments) | Where-Object { $_ -and ($_.state -ine "expired") }) {
                $targetEmail = if ($assignment.target.PSObject.Properties.Name -contains 'email') { " [$($assignment.target.email)]" }
                $description = "$($assignment.target.displayName)$targetEmail in $($accessPackage.displayName)"
                Write-Verbose "$logPrefix Queuing adminRemove for $description"
                $params = @{ requestType = "adminRemove"; assignment = @{id = $assignment.id } }
                try {
                    $req = Invoke-EntraOpsMsGraphQuery -Method POST -Uri "/v1.0/identityGovernance/entitlementManagement/assignmentRequests" -Body ($params | ConvertTo-Json -Depth 10) -OutputType PSObject -ThrowOnFailure
                    if (-not $req -or -not $req.id) { throw "Graph returned no assignment removal request ID." }
                    $removeRequests.Add([pscustomobject]@{
                            Id              = $req.id
                            AccessPackageId = $accessPackage.Id
                            Description     = $description
                        })
                } catch {
                    $failures.Add("adminRemove of assignment $($assignment.id) ($($assignment.target.displayName)) in $($accessPackage.displayName) failed: $($_.Exception.Message)")
                }
            }
        }

        Write-Progress -Id $progressId -Activity $progressActivity -Status 'Waiting for assignment removals'

        # Step 3: A terminal request may have failed; only successful removals permit teardown.
        if ($removeRequests.Count -gt 0) {
            Write-Verbose "$logPrefix Waiting for $($removeRequests.Count) assignment removal request(s) to complete"
            $result = Wait-OpenRequest -RequestIds @($removeRequests | ForEach-Object { $_.Id }) -Activity "Assignment removals"
            foreach ($request in $result.Requests) {
                $removeRequest = $removeRequests | Where-Object { $_.Id -eq $request.id } | Select-Object -First 1
                $state = if ($request.state) { $request.state } else { $request.status }
                if (Test-OpenRequest $request) {
                    $failures.Add("removal of $($removeRequest.Description) is still in progress after the wait budget (request $($request.id), state '$($request.state)', status '$($request.status)')")
                } elseif ($state -notin @('delivered', 'fulfilled')) {
                    $failures.Add("removal of $($removeRequest.Description) did not succeed (request $($request.id), state '$($request.state)', status '$($request.status)')")
                }
            }
        }
        Stop-IncompleteRemoval

        Write-Progress -Id $progressId -Activity $progressActivity -Status 'Verifying assignments were removed'

        # Confirm the assignment index has caught up before changing any package resource roles.
        $check = @{ Remaining = @() }
        $assignmentsRemoved = Wait-EntraOpsServiceEMCondition -Activity "Absence of active access package assignments" -logPrefix $logPrefix -ProgressStatus { "$($check.Remaining.Count) assignment(s) still visible" } -Condition {
            $check.Remaining = @(foreach ($accessPackage in $catalog.accessPackages) {
                $assignments = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/identityGovernance/entitlementManagement/assignments?`$count=true&`$filter=accessPackage/id eq '$($accessPackage.Id)'&`$expand=target" -OutputType PSObject -DisableCache -ConsistencyLevel "eventual" -ThrowOnFailure
                foreach ($assignment in @($assignments) | Where-Object { $_ -and $_.state -ine 'expired' }) {
                    "$($assignment.id) in $($accessPackage.displayName) (state '$($assignment.state)')"
                }
            })
            if ($check.Remaining.Count -gt 0) {
                Write-Verbose "$logPrefix $($check.Remaining.Count) assignment(s) still present: $($check.Remaining -join '; ')"
            }
            $check.Remaining.Count -eq 0
        }
        if (-not $assignmentsRemoved) {
            $failures.Add("assignments still present after removal: $($check.Remaining -join '; ')")
        }
        Stop-IncompleteRemoval

        Write-Progress -Id $progressId -Activity $progressActivity -Status 'Deleting access packages'

        # Step 4: Remove resource role scopes, then delete packages. Only the known grants-index error is retried.
        foreach ($accessPackage in $catalog.accessPackages) {
            try {
                $resourceRoleScopes = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/identityGovernance/entitlementManagement/accessPackages/$($accessPackage.Id)/resourceRoleScopes" -OutputType PSObject -DisableCache -ThrowOnFailure
                foreach ($rrs in @($resourceRoleScopes) | Where-Object { $_ -and $_.Id }) {
                    Write-Verbose "$logPrefix Removing resource role scope $($rrs.Id) from $($accessPackage.displayName)"
                    Invoke-EntraOpsMsGraphQuery -Method DELETE -Uri "/v1.0/identityGovernance/entitlementManagement/accessPackages/$($accessPackage.Id)/resourceRoleScopes/$($rrs.Id)" -ThrowOnFailure | Out-Null
                }
                $deleteCheck = @{ LastError = $null }
                $deleted = Wait-EntraOpsServiceEMCondition -Activity "Deletion of $($accessPackage.displayName)" -MaxWaitSeconds 60 -logPrefix $logPrefix -Condition {
                    try {
                        Invoke-EntraOpsMsGraphQuery -Method DELETE -Uri "/v1.0/identityGovernance/entitlementManagement/accessPackages/$($accessPackage.Id)" -ThrowOnFailure -SuppressBadRequestWarning | Out-Null
                        return $true
                    } catch {
                        if ($_.Exception.Data['StatusCode'] -ne 400 -or $_.Exception.Data['ErrorCode'] -ne 'InvalidDeleteEntitlementHasGrants') { throw }
                        $deleteCheck.LastError = $_.Exception.Message
                        Write-Verbose "$logPrefix Grants index still prevents deletion of $($accessPackage.displayName); retrying within the 60-second budget"
                        return $false
                    }
                }
                if (-not $deleted) { throw "Active grants still prevent package deletion after the retry budget: $($deleteCheck.LastError)" }
            } catch {
                $failures.Add("deleting access package $($accessPackage.displayName) failed: $($_.Exception.Message)")
            }
        }
        Stop-IncompleteRemoval

        Write-Progress -Id $progressId -Activity $progressActivity -Status 'Deleting service groups'

        # Step 5: Delete the Entra groups created by the landing zone (identified by their mailNickname).
        # The catalog is deleted afterwards, so a re-run can still find it when a group deletion fails.
        $serviceName = $ServiceCatalogName -replace '^Catalog-'
        Write-Verbose "$logPrefix Deleting associated Entra groups from catalog resources"
        $deletedGroupIds = [System.Collections.Generic.List[string]]::new()
        $removalFailures = [System.Collections.Generic.List[string]]::new()
        foreach ($resource in $catalog.Resources | Where-Object { $_.OriginSystem -eq "AadGroup" }) {
            if ($resource.OriginId -in $ExcludeGroupIds) {
                Write-Verbose "$logPrefix Skipping excluded group: $($resource.DisplayName) [$($resource.OriginId)]"
                continue
            }
            try {
                $group = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/groups/$($resource.OriginId)?`$select=id,displayName,mailNickname" -OutputType PSObject -DisableCache -ThrowOnFailure -SuppressNotFoundWarning
            } catch {
                if ($_.Exception.Data['StatusCode'] -ne 404) {
                    $removalFailures.Add("lookup of group $($resource.DisplayName) [$($resource.OriginId)] failed: $($_.Exception.Message)")
                    continue
                }
                $group = $null
            }
            if (-not $group) {
                Write-Verbose "$logPrefix Group $($resource.DisplayName) [$($resource.OriginId)] not found, skipping"
                continue
            }
            if ($group.MailNickname -notlike "$serviceName.*") {
                Write-Warning "$logPrefix Keeping group $($resource.DisplayName) [$($resource.OriginId)]: it wasn't created by this landing zone (mailNickname '$($group.MailNickname)')"
                continue
            }
            try {
                Write-Verbose "$logPrefix Deleting group: $($resource.DisplayName) [$($resource.OriginId)]"
                Invoke-EntraOpsMsGraphQuery -Method DELETE -Uri "/v1.0/groups/$($resource.OriginId)" -ThrowOnFailure | Out-Null
                $deletedGroupIds.Add($resource.OriginId)
            } catch {
                Write-Verbose "$logPrefix Failed to delete group $($resource.DisplayName) [$($resource.OriginId)]"
                $removalFailures.Add("deleting group $($resource.DisplayName) [$($resource.OriginId)] failed: $($_.Exception.Message)")
            }
        }
        if ($deletedGroupIds.Count -gt 0 -and (Get-EntraOpsServiceEMConfigValue -Path 'UpdateObjectClassificationFile') -eq $true) {
            Update-EntraOpsServiceEMObjectClassificationFile -RemoveObjectIds $deletedGroupIds -logPrefix $logPrefix | Out-Null
        }

        Write-Progress -Id $progressId -Activity $progressActivity -Status 'Deleting the catalog'

        # Step 6: Delete the catalog, unless a group is left that a re-run has to find through it.
        if ($removalFailures.Count -gt 0) {
            Write-Warning "$logPrefix Keeping catalog '$ServiceCatalogName': $($removalFailures.Count) group(s) could not be removed"
        } else {
            try {
                Write-Verbose "$logPrefix Deleting Catalog"
                Invoke-EntraOpsMsGraphQuery -Method DELETE -Uri "/v1.0/identityGovernance/entitlementManagement/catalogs/$($catalog.Id)" -ThrowOnFailure | Out-Null
            } catch {
                $removalFailures.Add("deleting catalog $ServiceCatalogName [$($catalog.Id)] failed: $($_.Exception.Message)")
            }
        }

        Write-Progress -Id $progressId -Activity $progressActivity -Status 'Processing Azure resource cleanup'

        # Step 7: Delete Azure resource group (opt-in).
        # Only the Rg scope creates a resource group, named without its "Rg-" prefix (see New-EntraOpsServiceAZContainer)
        $rgName = "RG-$($serviceName -replace '^Rg-')"
        if ($serviceName -like 'Sub-*') {
            $azureSummary = 'subscription role cleanup requires manual review'
            Write-Warning "$logPrefix '$ServiceCatalogName' is a subscription scope: no resource group is deleted. Remove Azure role assignments and PIM eligible assignments of the deleted groups on the subscription manually."
        } elseif ($RemoveAzureResourceGroup) {
            $previousAzContext = Get-AzContext
            try {
                if ($previousAzContext.Subscription.Id -ne $SubscriptionId) {
                    Write-Verbose "$logPrefix Switching Azure context to subscription $SubscriptionId"
                    Set-AzContext -Subscription $SubscriptionId -Tenant $previousAzContext.Tenant.Id -ErrorAction Stop | Out-Null
                }
                Write-Verbose "$logPrefix Looking up Azure Resource Group: $rgName"
                $rg = Get-AzResourceGroup -Name $rgName -ErrorAction Stop
                if ($rg -and $rg.Tags.EntraOpsServiceEM -ne $serviceName) {
                    Write-Warning "$logPrefix Keeping Azure Resource Group '$rgName': it isn't tagged EntraOpsServiceEM=$serviceName, so it wasn't created by this landing zone. Delete it manually if needed."
                } elseif ($rg) {
                    # PIM eligible schedules outlive the resource group and would block a later landing zone with the same name
                    try {
                        $schedules = @(Get-AzRoleEligibilitySchedule -Scope $rg.ResourceId -ErrorAction Stop | Where-Object { $_.Scope -ieq $rg.ResourceId })
                        foreach ($schedule in $schedules) {
                            Write-Verbose "$logPrefix Removing eligible assignment $($schedule.RoleDefinitionDisplayName) of $($schedule.PrincipalId) on $rgName"
                            New-AzRoleEligibilityScheduleRequest -Name ([guid]::NewGuid()) -Scope $rg.ResourceId -RoleDefinitionId $schedule.RoleDefinitionId -PrincipalId $schedule.PrincipalId -RequestType AdminRemove -ErrorAction Stop | Out-Null
                        }
                    } catch {
                        $azureCleanupNeedsAttention = $true
                        Write-Warning "$logPrefix Failed to remove PIM eligible assignments on '$rgName': $($_.Exception.Message). They may remain as leftovers."
                    }
                    Write-Verbose "$logPrefix Deleting Azure Resource Group: $rgName"
                    Remove-AzResourceGroup -Name $rgName -Force -ErrorAction Stop | Out-Null
                    $azureSummary = "Azure resource group '$rgName' deleted"
                    Write-Verbose "$logPrefix Azure Resource Group deleted: $rgName"
                }
            } catch {
                if ($_.Exception.Message -like "*not exist*" -or $_.Exception.Message -like "*ResourceGroupNotFound*") {
                    $azureSummary = "Azure resource group '$rgName' not found"
                    Write-Verbose "$logPrefix Azure Resource Group not found: $rgName — skipping"
                } else {
                    $azureCleanupNeedsAttention = $true
                    Write-Verbose "$logPrefix Failed to delete Azure Resource Group: $rgName"
                    Write-Error $_
                }
            } finally {
                if ($previousAzContext -and (Get-AzContext).Subscription.Id -ne $previousAzContext.Subscription.Id) {
                    Write-Verbose "$logPrefix Restoring Azure context to subscription $($previousAzContext.Subscription.Id)"
                    Set-AzContext -Context $previousAzContext -ErrorAction SilentlyContinue | Out-Null
                }
            }
        } else {
            Write-Warning "$logPrefix Azure Resource Group '$rgName' is kept (use -RemoveAzureResourceGroup to delete it). Role assignments of the deleted groups on it remain as orphaned assignments."
        }

        if ($removalFailures.Count -gt 0) {
            throw "$logPrefix Removal of '$ServiceCatalogName' is incomplete; resolve the following and run the cmdlet again: $($removalFailures -join '; ')"
        }
        if ($azureCleanupNeedsAttention) {
            Write-Host "$logPrefix Catalog '$ServiceCatalogName' removed; $($deletedGroupIds.Count) service group(s) deleted; Azure cleanup needs attention. See the warnings and errors above."
        } else {
            Write-Host "$logPrefix Catalog '$ServiceCatalogName' removed; $($deletedGroupIds.Count) service group(s) deleted; $azureSummary."
        }
    }

    end {
        return @{}
    }

    clean {
        if ($progressActivity) { Write-Progress -Id $progressId -Activity $progressActivity -Completed }
    }
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDR7KqoYtLAAMj4
# ShjyC3h4U7BOFRzX7VfloQ8PtlJL9KCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# AEkoya49fu0nMA0GCSqGSIb3DQEBDAUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK
# ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2Vy
# dGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5l
# dHdvcmsgQ0EwHhcNMjEwNTMxMDY0MzA2WhcNMjkwOTE3MDY0MzA2WjCBgDELMAkG
# A1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAl
# BgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMb
# Q2VydHVtIFRydXN0ZWQgTmV0d29yayBDQSAyMIICIjANBgkqhkiG9w0BAQEFAAOC
# Ag8AMIICCgKCAgEAvfl4+ObVgAxknYYblmRnPyI6HnUBfe/7XGeMycxca6mR5rlC
# 5SBLm9qbe7mZXdmbgEvXhEArJ9PoujC7Pgkap0mV7ytAJMKXx6fumyXvqAoAl4Va
# qp3cKcniNQfrcE1K1sGzVrihQTib0fsxf4/gX+GxPw+OFklg1waNGPmqJhCrKtPQ
# 0WeNG0a+RzDVLnLRxWPa52N5RH5LYySJhi40PylMUosqp8DikSiJucBb+R3Z5yet
# /5oCl8HGUJKbAiy9qbk0WQq/hEr/3/6zn+vZnuCYI+yma3cWKtvMrTscpIfcRnNe
# GWJoRVfkkIJCu0LW8GHgwaM9ZqNd9BjuiMmNF0UpmTJ1AjHuKSbIawLmtWJFfzcV
# WiNoidQ+3k4nsPBADLxNF8tNorMe0AZa3faTz1d1mfX6hhpneLO/lv403L3nUlbl
# s+V1e9dBkQXcXWnjlQ1DufyDljmVe2yAWk8TcsbXfSl6RLpSpCrVQUYJIP4ioLZb
# MI28iQzV13D4h1L92u+sUS4Hs07+0AnacO+Y+lbmbdu1V0vc5SwlFcieLnhO+Nqc
# noYsylfzGuXIkosagpZ6w7xQEmnYDlpGizrrJvojybawgb5CAKT41v4wLsfSRvbl
# jnX98sy50IdbzAYQYLuDNbdeZ95H7JlI8aShFf6tjGKOOVVPORa5sWOd/7cCAwEA
# AaOCAT4wggE6MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLahVDkCw6A/joq8
# +tT4HKbROg79MB8GA1UdIwQYMBaAFAh2zcsH/yT2xc3tu5C84oQ3RnX3MA4GA1Ud
# DwEB/wQEAwIBBjAvBgNVHR8EKDAmMCSgIqAghh5odHRwOi8vY3JsLmNlcnR1bS5w
# bC9jdG5jYS5jcmwwawYIKwYBBQUHAQEEXzBdMCgGCCsGAQUFBzABhhxodHRwOi8v
# c3ViY2Eub2NzcC1jZXJ0dW0uY29tMDEGCCsGAQUFBzAChiVodHRwOi8vcmVwb3Np
# dG9yeS5jZXJ0dW0ucGwvY3RuY2EuY2VyMDkGA1UdIAQyMDAwLgYEVR0gADAmMCQG
# CCsGAQUFBwIBFhhodHRwOi8vd3d3LmNlcnR1bS5wbC9DUFMwDQYJKoZIhvcNAQEM
# BQADggEBAFHCoVgWIhCL/IYx1MIy01z4S6Ivaj5N+KsIHu3V6PrnCA3st8YeDrJ1
# BXqxC/rXdGoABh+kzqrya33YEcARCNQOTWHFOqj6seHjmOriY/1B9ZN9DbxdkjuR
# mmW60F9MvkyNaAMQFtXx0ASKhTP5N+dbLiZpQjy6zbzUeulNndrnQ/tjUoCFBMQl
# lVXwfqefAcVbKPjgzoZwpic7Ofs4LphTZSJ1Ldf23SIikZbr3WjtP6MZl9M7JYjs
# NhI9qX7OAo0FmpKnJ25FspxihjcNpDOO16hO0EoXQ0zF8ads0h5YbBRRfopUofbv
# n3l6XYGaFpAP4bvxSgD5+d2+7arszgowggZrMIIEU6ADAgECAhBX5gfP8LabvnxC
# XoItEBa/MA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhB
# c3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBDb2RlIFNp
# Z25pbmcgMjAyMSBDQTAeFw0yNjEwMDYwODI4MjNaFw0yNzEwMDYwODI4MjJaMIGQ
# MQswCQYDVQQGEwJERTEdMBsGA1UECAwUUmhpbmVsYW5kLVBhbGF0aW5hdGUxEjAQ
# BgNVBAcMCUxhaG5zdGVpbjEeMBwGA1UECgwVT3BlbiBTb3VyY2UgRGV2ZWxvcGVy
# MS4wLAYDVQQDDCVPcGVuIFNvdXJjZSBEZXZlbG9wZXIgVGhvbWFzIE5hdW5oZWlt
# MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAphp2ljSmVsRB6gvkEq7A
# DUMENpKZiDFoAfF1ktD1nCwaVyI+bJ0ROjHwAn4iJgS+vlBDPOUIw2GLHkiqUhOg
# HSwzg6gj45GNmt6zxsKP0zPlhMB/fb0zmz7/F7e3yfKCXLfhbHuk0Es9gJ+8Wmet
# 9StVwwSE/K0MpW06GKA9nPWyE4N/ADwigDIj9rsTKJR4cve9w8G1b3zohpUc/qMr
# onab+HVw5r9b3sOBnnaXC6zvrX4fovRz1ztwfWjRjILLoFG6mW/TuK5F2ICD3Rhl
# 9R2rJWWWhzTDMsQlzvX+/SaM74q3OFpIVNdhvtmWU9pKUBRAyYMijcDqKEFGSzrc
# 8JFmGMpP5TuroWF+N21d3px2eARvW1mV0DCfFAH+Og20SH/gVW6/ltQQEkH/z/Zt
# 49ARyJrgESLOQtlMCIYSoKISdkk4ml4VUS1E/PcbzXEYj3a7m6DOxcd3WuY03UT9
# cnk3p0LMN6LeLYAZgVY+HRloZlH+kW2SkOHNuWgtXvZhAgMBAAGjggF4MIIBdDAM
# BgNVHRMBAf8EAjAAMD0GA1UdHwQ2MDQwMqAwoC6GLGh0dHA6Ly9jY3NjYTIwMjEu
# Y3JsLmNlcnR1bS5wbC9jY3NjYTIwMjEuY3JsMHMGCCsGAQUFBwEBBGcwZTAsBggr
# BgEFBQcwAYYgaHR0cDovL2Njc2NhMjAyMS5vY3NwLWNlcnR1bS5jb20wNQYIKwYB
# BQUHMAKGKWh0dHA6Ly9yZXBvc2l0b3J5LmNlcnR1bS5wbC9jY3NjYTIwMjEuY2Vy
# MB8GA1UdIwQYMBaAFN10XUwA23ufoHTKsW73PMAywHDNMB0GA1UdDgQWBBTBvGyH
# MXj1dMbVnxuof9oP4PY7cDBLBgNVHSAERDBCMAgGBmeBDAEEATA2BgsqhGgBhvZ3
# AgUBBDAnMCUGCCsGAQUFBwIBFhlodHRwczovL3d3dy5jZXJ0dW0ucGwvQ1BTMBMG
# A1UdJQQMMAoGCCsGAQUFBwMDMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsF
# AAOCAgEAkC165c7/r6y3VeEEpNXas9bQs6ZpKPdJffHkf9xoPi4kWvAIWGc4ocG1
# DJLb18RXiDYShDmT5sb/6I08FeipKi2DNJkuOq6yOTJ3S7+hNFIZCyd6i7WKO8ea
# P5I+FAbyJP4+5coZoMg45bUa9JoocaJCm+5n7WiCfN7/EQpClW5Trm4+ZMbp7EMN
# F1uLJgGCMnN0SsoUarp7PHlqupMxMC4Uhhe+lAP2ytcSIU81L97/qjrADbLWqICk
# 9il5iGM+aZndguq/cmfH9rJj4GRZHWnG6WwnzYD9+KgYlRI6em+/9dq+IRiXe2Bs
# thUuIaFW7U2gWw8tBTEYm1UBLHdJx+L1gjJCLs5KT/wy5wU32wZxNDqPUQejUC1x
# GvU3cKqmLgRq+yNf3gFVfzqIoggyM3U9wQvnk7nBtCkpjXS2hdx4yxzcmrlWsDHr
# z6+6/jeyQySzbYUlFmKnc4n9Mw2soHOajEua5poFARVRS5KgUBuiiQ21YFNYzWBo
# qGuECaaAI/C5rGxapMJ3dAOBnHw6NLqBet+ZXBJEccX5y89MLlOQu+jDmJP8HieR
# qDLlwfhVZnw4RY8Rus43NZBwhNlVuOUXqocHKmJhKNlekda3qsJj93eQBQOxKJcc
# w5O08q9jukpH2+yzFAj+7ll33DuNUgWptogc4Awtbx3glzcEvJQwggaCMIIEaqAD
# AgECAhAo8HfBHDa9/l90MkdwJy4DMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYT
# AlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMT
# G0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQTAeFw0yNjAzMTEwNzM0NTRaFw0z
# NjAyMjcwNzM0NTRaMFAxCzAJBgNVBAYTAlBMMSEwHwYDVQQKDBhBc3NlY28gRGF0
# YSBTeXN0ZW1zIFMuQS4xHjAcBgNVBAMMFUNlcnR1bSBUaW1lc3RhbXAgMjAyNjCC
# AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALi9zfBw+xI12hi2F9uNQBNO
# STkTIi13fuh3DiWybI5392bBfJw6tO7zDs7UqP/rejVf4xFTU3XNOPsuOFkP5SPF
# snWwn3VJL+NhNhsuujvYsQtaQnvNo1wk46reTgws1L8OFEdKaxgyqT4zOQx81mLw
# NZSqXNU9Rrb9oAJWJF4Mydz+C1ebu0D0+vgM3tqAQ8TU203fqgKCPqIdHloaFqH9
# eyR+8tnpmy2hnnN59ZOtmsuHeRwf5iTOhlhf0d/qqquLOSIGPgGVL1Hj5XBUox2O
# E7tQRr+l/xxNbL6OuwBO4Aa2EbuxYg37cpXv5i2/lTL4NASB/duf9W4fjA3Ro+GP
# +bC54n2OYiM3Cq8z92ejx8F5HWO2e4/75eC95B9tMw/eq7109GDyW2+2ut5Bn0o8
# MXJTERkyystWbqcnUkomA97qfiDCwvpWuOMdfTOksXe9S+xDxOJaCbiyTivgPulx
# cT+tfQTRXBr5UwtPvc+INnafOEilzVHA11tv+pHcylryxBc08cUihf/ttqkZS0bm
# x3VxQay8uohaTp0//ECA9vYGW51SKdJMJhXvKyIdBLy8WYNchTt8yRITv9Adxg9J
# qGujh+TnP1bW3aQjFtlEl7SzgNeI8OVA/CJAWWUuRT3Au9ToOpHy/flrrknhN4he
# Ew7/6ILMv4HSsj0YZ4otAgMBAAGjggFQMIIBTDB1BggrBgEFBQcBAQRpMGcwOwYI
# KwYBBQUHMAKGL2h0dHA6Ly9zdWJjYS5yZXBvc2l0b3J5LmNlcnR1bS5wbC9jdHNj
# YTIwMjEuY2VyMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u
# Y29tMB8GA1UdIwQYMBaAFL5UAi+/QGxzQ86sCSVOnkNEGu7gMAwGA1UdEwEB/wQC
# MAAwOQYDVR0fBDIwMDAuoCygKoYoaHR0cDovL3N1YmNhLmNybC5jZXJ0dW0ucGwv
# Y3RzY2EyMDIxLmNybDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAOBgNVHQ8BAf8E
# BAMCB4AwIgYDVR0gBBswGTAIBgZngQwBBAIwDQYLKoRoAYb2dwIFAQswHQYDVR0O
# BBYEFCM5aiirmhKnpKfyH/lFmr+N/oIDMA0GCSqGSIb3DQEBDAUAA4ICAQBm/pOb
# rAveTqYFPCSthxNVwBHTW4TfuBQY2KGCdMlH6ALjVPYf2XARNGMTXScR0JQ2/c2L
# xLpoHHhRwxFf3d933DhFS165rYj6iPSQKm41rc9AMpTCjMdmai1aK9o870pOzTzL
# RhWFVIxm+5Qf+t4V5z14tPoUsAWdfRKD1Zzvzfe5AHlE9rxOZcGROiULxs3Edf7Z
# J8RZI0jzD9juj2oEX2XS7BVKuNCK1W0h1mjapv028yqo8UtZh5/1Ib48Yyp88BDY
# Shh5PSGUUuS5tYXVfcduIPBe3PdNTYRYdT5VNxkwLgjMzIcr/6SH2fejFJDjnCxj
# vXLuarErJg/IdI+hXdGIBDVOGv1zrkF4ktoQvFZIp5DIAYh0GwyUhc87qqn5UaYT
# j2+iyiBDSNh7aGCrkuAnqg7Qh7frhX+QhMGeJ9SpLpnG76qN85hLyjvYR6gRYiLT
# j8G4fxjqV400/buPw0rBo9yJZyq/8cGdCOLPOYf9qpefTcrZKeO9pg+UJHx8UABb
# aTqizRxm8sUSEycccCCFhIit3AI/3wGuSOADeiA676NcQ7+dsP04YvzvdN7pTZNy
# g672WpBDJdg0eItHl4yomvNeIOWh1qs4QqOW6TI8Ocsi4uSDTLorxLUDnBTwKQzz
# pxd1YUctEgRh32R0J9FkFkeEruJU94RljFr1uDCCBrkwggShoAMCAQICEQCZo4AK
# JlU7ZavcboSms+o5MA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAG
# A1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVt
# IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3Rl
# ZCBOZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjE4WhcNMzYwNTE4MDUzMjE4WjBW
# MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEu
# MSQwIgYDVQQDExtDZXJ0dW0gQ29kZSBTaWduaW5nIDIwMjEgQ0EwggIiMA0GCSqG
# SIb3DQEBAQUAA4ICDwAwggIKAoICAQCdI88EMCM7wUYs5zNzPmNdenW6vlxNur3r
# Lfi+5OZ+U3iZIB+AspO+CC/bj+taJUbMbFP1gQBJUzDUCPx7BNLgid1TyztVLn52
# NKgxxu8gpyTr6EjWyGzKU/gnIu+bHAse1LCitX3CaOE13rbuHbtrxF2tPU8f253Q
# gX6eO8yTbGps1Mg+yda3DcTsOYOhSYNCJiL+5wnjZ9weoGRtvFgMHtJg6i671OPX
# IciiHO4Lwo2p9xh/tnj+JmCQEn5QU0NxzrOiRna4kjFaA9ZcwSaG7WAxeC/xoZSx
# F1oK1UPZtKVt+yrsGKqWONoK6f5EmBOAVEK2y4ATDSkb34UD7JA32f+Rm0wsr5aj
# zftDhA5mBipVZDjHpwzv8bTKzCDUSUuUmPo1govD0RwFcTtMXcfJtm1i+P2UNXad
# PyYVKRxKQATHN3imsfBiNRdN5kiVVeqP55piqgxOkyt+HkwIA4gbmSc3hD8ke66t
# 9MjlcNg73rZZlrLHsAIV/nJ0mmgSjBI/TthoGJDydekOQ2tQD2Dup/+sKQptalDl
# ui59SerVSJg8gAeV7N/ia4mrGoiez+SqV3olVfxyLFt3o/OQOnBmjhKUANoKLYlK
# mUpKEFI0PfoT8Q1W/y6s9LTI6ekbi0igEbFUIBE8KDUGfIwnisEkBw5KcBZ3XwnH
# mfznwlKo8QIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
# 3XRdTADbe5+gdMqxbvc8wDLAcM0wHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61Pgc
# ptE6Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDAGA1Ud
# HwQpMCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYI
# KwYBBQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0
# dW0uY29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwv
# Y3RuY2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0
# cDovL3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQB1iFgP5Y9Q
# KJpTnxDsQ/z0O23JmoZifZdEOEmQvo/79PQg9nLF/GJe6ZiUBEyDBHMtFRK0mXj3
# Qv3gL0sYXe+PPMfwmreJHvgFGWQ7XwnfMh2YIpBrkvJnjwh8gIlNlUl4KENTK5DL
# qsYPEtRQCw7R6p4s2EtWyDDr/M58iY2UBEqfUU/ujR9NuPyKk0bEcEi62JGxauFY
# zZ/yld13fHaZskIoq2XazjaD0pQkcQiIueL0HKiohS6XgZuUtCKA7S6CHttZEsOb
# QJ1j2s0urIDdqF7xaXFVaTHKtAuMfwi0jXtF3JJphrJfc+FFILgCbX/uYBPBlbBI
# P4Ht4xxk2GmfzMn7oxPITpigQFJFWuzTMUUgdRHTxaTSKRJ/6Uh7ki/pFjf9sUAS
# WgxT69QF9Ki4JF5nBIujxZ2sOU9e1HSCJwOfK07t5nnzbs1LbHuAIGJsRJiQ6HX/
# DW1XFOlXY1rc9HufFhWU+7Uk+hFkJsfzqBz3pRO+5aI6u5abI4Qws4YaeJH7H7M8
# X/YNoaArZbV4Ql+jarKsE0+8XvC4DJB+IVcvC9Ydqahi09mjQse4fxfef0L7E3hh
# o2O3bLDM6v60rIRUCi2fJT2/IRU5ohgyTch4GuYWefSBsp5NPJh4QRTP9DC3gc5Q
# EKtbrTY0Ka87Web7/zScvLmvQBm8JDFpDjCCBrkwggShoAMCAQICEQDn/2nHOzXO
# S5Em2HR8aKWHMA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UE
# ChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl
# cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBO
# ZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjA3WhcNMzYwNTE4MDUzMjA3WjBWMQsw
# CQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQw
# IgYDVQQDExtDZXJ0dW0gVGltZXN0YW1waW5nIDIwMjEgQ0EwggIiMA0GCSqGSIb3
# DQEBAQUAA4ICDwAwggIKAoICAQDpEh8ENe25XXrFppVBvoplf0530W0lddNmjtv4
# YSh/f7eDQKFaIqc7tHj7ox+u8vIsJZlroakUeMS3i3T8aJRC+eQs4FF0GqvkM6+W
# ZO8kmzZfxmZaBYmMLs8FktgFYCzywmXeQ1fEExflee2OpbHVk665eXRHjH7MYZIz
# Nnjl2m8Hy8ulB9mR8wL/W0v0pjKNT6G0sfrx1kk+3OGosFUb7yWNnVkWKU4qSxLv
# 16kJ6oVJ4BSbZ4xMak6JLeB8szrK9vwGDpvGDnKCUMYL3NuviwH1x4gZG0JAXU3x
# 2pOAz91JWKJSAmRy/l0s0l5bEYKolg+DMqVhlOANd8Yh5mkQWaMEvBRE/kAGzIqg
# WhwzN2OsKIVtO8mf5sPWSrvyplSABAYa13rMYnzwfg08nljZHghquCJYCa/xHK9a
# cev9UD7Y+usr15d7mrszzxhF1JOr1Mpup2chNSBlyOObhlSO16rwrffVrg/SzaKf
# SndS5swRhr8bnDqNJY9TNyEYvBYpgF95K7p0g4LguR4A++Z1nFIHWVY5v0fNVZmg
# zxD9uVo/gta3onGOQj3JCxgYx0KrCXu4yc9QiVwTFLWbNdHFSjBCt5/8Q9pLuRhV
# ocdCunhcHudMS1CGQ/Rn0+7P+fzMgWdRKfEOh/hjLrnQ8BdJiYrZNxvIOhM2aa3z
# EDHNwwIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUvlQC
# L79AbHNDzqwJJU6eQ0Qa7uAwHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61PgcptE6
# Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMDAGA1UdHwQp
# MCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYIKwYB
# BQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u
# Y29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwvY3Ru
# Y2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0cDov
# L3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQC4k1l3yUwV/ZQH
# CKCneqAs8EGTnwEUJLdDpokN/dMhKjK0rR5qX8nIIHzxpQR3TAw2IRw1Uxsr2Pli
# G3bCFqSdQTUbfaTq6V3vBzEebDru9QFjqlKnxCF2h1jhLNFFplbPJiW+JSnJTh1f
# KEqEdKdxgl9rVTvlxfEJ7exOn25MGbd/wGPwuSmMxRJVO0wnqgS7kmoJjNF9zqee
# hFSDDP8ZVkWg4EZ2tIS0M3uZmByRr+1Lkwjjt8AtW83mVnZTyTsOb+FNfwJY7DS4
# FmWhkRbgcHRetreoTirPOr/ozyDKhT8MTSTf6Lttg6s6T/u08mDWw6HK04ZRDfQ9
# sb77QV8mKgO44WGP31vXnVKoWVJpFBjPvjL8/Zck/5wXX2iqjOaLStFOR/IQki+E
# hn4zlcgVm22ZVCBPF+l8nAwUUShCtKuSU7GmZLKCmmxQMkSiWILTm8EtVD6AxnJh
# oq8EnhjEEyUoflkeRF2WhFiVQOmWTwZRr44IxWGkNJC6tTorW5rl2Zl+2e9JLPYf
# 3pStAPMDoPKIjVXd6NW2+fZrNUBeDo2eOa5Fn7Brs/HLQff5Xgris5MeUbdVgDrF
# 8uxO6cLPvZPo63j62SsNg55pTWk9fUIF9iPoRbb4QurjoY/woI1RAOKtYtTic6aA
# Jq3u83RIPpGXBSJKwx4KJAOZnCDCtTGCBqIwggaeAgEBMGowVjELMAkGA1UEBhMC
# UEwxITAfBgNVBAoTGEFzc2VjbyBEYXRhIFN5c3RlbXMgUy5BLjEkMCIGA1UEAxMb
# Q2VydHVtIENvZGUgU2lnbmluZyAyMDIxIENBAhBX5gfP8LabvnxCXoItEBa/MA0G
# CWCGSAFlAwQCAQUAoIGEMBgGCisGAQQBgjcCAQwxCjAIoAKAAKECgAAwGQYJKoZI
# hvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcC
# ARUwLwYJKoZIhvcNAQkEMSIEIBspRC75Hv9r15bJQHE6fnV/mjNH7veQBXLy/Sfg
# UvQuMA0GCSqGSIb3DQEBAQUABIIBgBIjuYUIGR4uuX+yqqO+im1OFNT3kgoNrbqg
# JMpXVrofFuBXDb7TzU94jpPTYN9mZ8Y32kajZNU9SkHHWGVJDVVhXR+bfIP7D4Vv
# FVS6uSIVuVaQ5u0rrvO5bPU757wavUKgRi3+OHbIBWKc82EfAFOk7zkLj2/+Ac0F
# EUz7T0BNE8DN7y9+w5KNprSS2pclQaDaIpnMlTzreCW2imRoR6aAEDqtBqXDqAjc
# P3q8nNfL/B9vnT8aP5OI0NEzcA4YV402CKYMJt8gye45W4CYqvS5oDaegEfVmquu
# ro2v/6D/8ptv711v/pbXkmCVrXhK4YNp0eDKLfTcvsu7D18MaYUdTle9AjwxvQKK
# bwHBZNhfDdelvJKdCzVc5bfS0w9fheAoAKqe3OCu6/wZPyQxXz+Hiriw9xYe2owT
# Pv/ud4CxsZFYzeInGIJngw6cQd7F448mOpFYT0HxsNIBBcIwe5cPwXM8jOhde1/m
# Fhwt8Vc9CyyOQJcruNmZ8oG41VdapKGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMjcwNTQxMThaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDDooYkeg+NBVn5Q2thZhs6OIHES9aQ9welG
# 5Z8tLQ9/jN9EjX4sgM9XQtK6td8W3h4wgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAflx83HugoQ1a0urpuA66Dx/j7/IfrBnhd6GyCCbJXqJhASDp
# P82ause3ddwfWP267TnVYHgAoPdlElaXH6EfI4XhI7D8C/GQAWSJ2cFfY3u7ay6y
# +zlCeZ6pu9QwgYG17FSOEQ1txzLmsRFX1MOIbmnG/0iF82IXNh3QMx2AX6LZ2wMM
# xk0EG0d9W5hiDf9VUhDF39X6WI+C9kbW+sTIux8hdUDq0Jsd2zZuAlIb5PLSsW3L
# ekk+Lj7tbdZ5WcnlLez4qAu844xF82uIGClm/IH0muETRD14qaa3SocGwk4PM2nP
# 0lS7OoQgiJUTqonQaC8Je7EmtFoyCMlK5s24YldbNTJaYJ7f9jinVmm3OXxERbjE
# Yy7hMll/lrx9qTupwB0GOxe+9UzyPz1QcJuTR0VF/O11YvNcpkxPhbPH1qg9/U0v
# FYAUeaDu+jfOHxTyOL3DPS9DnzAXe6ObXH4HlF+giw2aWrxFNxx2uMnD/RGoAgWC
# TYW97yA+cux6glGQS5WPzJxiuwzFsRySt+D843dKySOzsTzPIFwIrBvZiqXBlknG
# aPF0cPNiV5vr3IDpF5OojeSieytCvYUKKW78Kk0dltfDSo8JKyDUfoCLEPevUieM
# gDJ0UZecoKu4dw+y7zwuK913J8yiY3HlM4wsIQ40d7t+bt/FT9pnERAbShc=
# SIG # End signature block