Private/Get-EntraOpsIdGovScopeClassification.ps1

<#
.SYNOPSIS
    Classify every Identity Governance access package catalog and access package scope
    (Tier0/ControlPlane, Tier1/ManagementPlane or Tier2/UserAccess) based on the resources
    actually assigned to them, for Classification_IdentityGovernance parameterization and
    ScopeReasoning_IdentityGovernance.json.

.DESCRIPTION
    Enumerates all Entitlement Management catalogs and their access packages via Microsoft Graph and
    determines the effective EAM tier of each scope from its assigned resources:

    - AadGroup : classification of the group object in the classified EntraOps EAM data
                            (Azure/EntraID/DeviceManagement json files).
    - DirectoryRole : classification of the directory role from classified EntraID EAM data at
                            directory scope, with fallback to the AzurePrivilegedIAM community
                            classification. Unclassifiable roles are treated as ControlPlane
                            (conservative).
    - OAuthApplication : ONLY evaluated on access package level. The catalog-level
                            accessPackageResourceRoles enumeration for API resources returns
                            permissions across catalogs (Microsoft Graph beta bug), so the union of
                            API permissions assigned to the catalog's access packages is used for
                            the catalog tier instead.
    - AadApplication : service-principal object classification from ResourceApps EAM data.
    - SharePointOnline : role-aware classification; administrative roles are ManagementPlane,
                            member/read roles are UserAccess, and unknown roles conservatively use
                            ManagementPlane.
    - AzureResources : ARM scope (originId) matched against the Tier0/Tier1 resource scope
                            buckets resolved by Get-EntraOpsClassificationAzureResourceScope. A scope
                            is Tier0/Tier1 when it hierarchically overlaps a bucketed scope path
                            (equals it, contains one beneath it, or lies beneath one) - shared
                            convention, see Find-EntraOpsAzureScopeContainmentMatch. Without
                            resolved buckets the resource is treated as ControlPlane (conservative).

    The scope tier is the most privileged tier found across all assigned resources. Scopes without
    any (classified) privileged resource are UserAccess. Unknown origin systems are treated as
    ControlPlane (conservative).

.PARAMETER EntraOpsEamFolder
    Path to the folder with the classified EntraOps EAM json files (e.g. ./PrivilegedEAM).

.PARAMETER FilterClassifiedRbacs
    RBAC systems whose classified EAM data is used to classify assigned groups.

.PARAMETER FolderClassification
    Folder path to the classification definition files (for the API permissions template).

.PARAMETER AzureResourceTierScope
    Result object of Get-EntraOpsClassificationAzureResourceScope (Tier0ResourceScope /
    Tier1ResourceScope) used to classify AzureResources catalog resources. Optional - without it,
    Azure resources are classified ControlPlane (conservative).

.PARAMETER WarningMessages
    Optional list to collect warning messages raised during classification.

.EXAMPLE
    Get-EntraOpsIdGovScopeClassification -EntraOpsEamFolder $DefaultFolderClassifiedEam -AzureResourceTierScope $SharedAzureResourceScope
#>


function Get-EntraOpsIdGovScopeClassification {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$EntraOpsEamFolder
        ,
        [Parameter(Mandatory = $false)]
        [ValidateSet("Azure", "EntraID", "DeviceManagement")]
        [Array]$FilterClassifiedRbacs = ("Azure", "EntraID", "DeviceManagement")
        ,
        [Parameter(Mandatory = $false)]
        [System.String]$FolderClassification = "$DefaultFolderClassification"
        ,
        [Parameter(Mandatory = $false)]
        [psobject]$AzureResourceTierScope
        ,
        [Parameter(Mandatory = $false)]
        [System.Collections.Generic.List[psobject]]$WarningMessages
    )

    function Add-IdGovScopeWarning {
        param([string]$Type, [string]$Message, [string]$Target)
        if ($null -ne $WarningMessages) {
            $WarningMessages.Add([PSCustomObject]@{ Type = $Type; Message = $Message; Target = $Target })
        } else {
            Write-Warning $Message
        }
    }

    # Map an EAM tier tag/name to a numeric rank (lower = more privileged) and normalized names.
    # Distinct rank per tier in canonical order (ControlPlane, ManagementPlane, WorkloadPlane,
    # UserAccess, Unclassified). Unknown values map to the Unclassified rank instead of being
    # asserted as a real UserAccess classification. Numeric tag values follow the canonical map
    # (ControlPlane=0, ManagementPlane=1, WorkloadPlane=1 by name only, UserAccess=2).
    function ConvertTo-IdGovTierRank {
        param([string]$TierValue)
        switch -Regex ($TierValue) {
            '^(0|ControlPlane)$' { return 0 }
            '^(1|ManagementPlane)$' { return 1 }
            '^WorkloadPlane$' { return 2 }
            '^(2|UserAccess)$' { return 3 }
            default { return 4 }
        }
    }
    $TierRankToName = @{ 0 = "ControlPlane"; 1 = "ManagementPlane"; 2 = "WorkloadPlane"; 3 = "UserAccess"; 4 = "Unclassified" }
    # Scope buckets: WorkloadPlane shares tag value 1 (Tier1) with ManagementPlane. Unclassified is
    # deliberately NOT bucketed as Tier2 so it can no longer masquerade as a real UserAccess scope.
    $TierRankToScope = @{ 0 = "Tier0"; 1 = "Tier1"; 2 = "Tier1"; 3 = "Tier2"; 4 = "Unclassified" }

    #region Build lookups from classified EntraOps EAM data
    # Classified principals (groups) by RBAC system - same source as Get-EntraOpsPrivilegedEamIdGov
    $ClassifiedObjectsByRbac = @{}
    $ClassifiedObjectsByRbacAndId = @{}
    foreach ($RbacSystem in $FilterClassifiedRbacs) {
        $ClassificationSource = Join-Path -Path $EntraOpsEamFolder -ChildPath $RbacSystem -AdditionalChildPath "$($RbacSystem).json"
        if (Test-Path -Path $ClassificationSource) {
            try {
                $ClassifiedObjectsByRbac[$RbacSystem] = @(Get-Content -Path $ClassificationSource -ErrorAction Stop | ConvertFrom-Json -Depth 10)
                $ObjectsById = @{}
                foreach ($ClassifiedObject in $ClassifiedObjectsByRbac[$RbacSystem]) {
                    if ([string]::IsNullOrEmpty($ClassifiedObject.ObjectId)) { continue }
                    if (-not $ObjectsById.ContainsKey($ClassifiedObject.ObjectId)) {
                        $ObjectsById[$ClassifiedObject.ObjectId] = [System.Collections.Generic.List[object]]::new()
                    }
                    $ObjectsById[$ClassifiedObject.ObjectId].Add($ClassifiedObject)
                }
                $ClassifiedObjectsByRbacAndId[$RbacSystem] = $ObjectsById
            } catch {
                Add-IdGovScopeWarning -Type "ClassificationSourceError" -Message "Failed to load classified EAM file $($ClassificationSource): $_" -Target $RbacSystem
            }
        } else {
            Add-IdGovScopeWarning -Type "ClassificationSourceMissing" -Message "Classified EAM file $ClassificationSource not found - groups classified in $RbacSystem cannot be considered for Identity Governance scope classification. Run the $RbacSystem RBAC system first." -Target $RbacSystem
        }
    }

    # AadApplication origin IDs are service-principal object IDs, so classify them from the
    # ResourceApps EAM output rather than from the API-permission appId lookup.
    $ResourceAppsClassificationCache = @{}
    $ResourceAppsFile = Join-Path -Path $EntraOpsEamFolder -ChildPath "ResourceApps" -AdditionalChildPath "ResourceApps.json"
    if (Test-Path -Path $ResourceAppsFile) {
        try {
            $ResourceApps = @(Get-Content -Path $ResourceAppsFile -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 10 -ErrorAction Stop)
            $ResourceAppsByObjectId = @{}
            foreach ($ResourceApp in $ResourceApps) {
                if ([string]::IsNullOrEmpty($ResourceApp.ObjectId)) { continue }
                if (-not $ResourceAppsByObjectId.ContainsKey($ResourceApp.ObjectId)) {
                    $ResourceAppsByObjectId[$ResourceApp.ObjectId] = [System.Collections.Generic.List[object]]::new()
                }
                $ResourceAppsByObjectId[$ResourceApp.ObjectId].Add($ResourceApp)
            }
            $ResourceAppsClassificationCache['ResourceApps:ByObjectId'] = $ResourceAppsByObjectId
        } catch {
            Add-IdGovScopeWarning -Type "ClassificationSourceError" -Message "Failed to load ResourceApps EAM file for AadApplication classification: $_" -Target "ResourceApps"
        }
    } else {
        Add-IdGovScopeWarning -Type "ClassificationSourceMissing" -Message "ResourceApps EAM file not found at $ResourceAppsFile - AadApplication resources will be treated as ControlPlane." -Target "ResourceApps"
    }

    # Directory role classification from classified EntraID EAM data (directory scope only)
    $EntraIdRoleAssignments = @()
    $EntraIdEamFile = Join-Path -Path $EntraOpsEamFolder -ChildPath "EntraID" -AdditionalChildPath "EntraID.json"
    if (Test-Path -Path $EntraIdEamFile) {
        try {
            $EntraIdRoleAssignments = (Get-Content -Path $EntraIdEamFile -ErrorAction Stop | ConvertFrom-Json -Depth 10).RoleAssignments
        } catch {
            Add-IdGovScopeWarning -Type "ClassificationSourceError" -Message "Failed to load EntraID EAM file for directory role classification: $_" -Target "EntraID"
        }
    }
    $EntraIdRolesByDefinitionId = @{}
    foreach ($RoleAssignment in @($EntraIdRoleAssignments)) {
        if ($RoleAssignment.RoleAssignmentScopeId -ne "/" -or [string]::IsNullOrEmpty($RoleAssignment.RoleDefinitionId)) { continue }
        if (-not $EntraIdRolesByDefinitionId.ContainsKey($RoleAssignment.RoleDefinitionId)) {
            $EntraIdRolesByDefinitionId[$RoleAssignment.RoleDefinitionId] = $RoleAssignment
        }
    }

    # Community default classification for Entra ID roles as fallback (same source as Get-EntraOpsPrivilegedEamIdGov)
    $EntraRolesDefaultClassification = @()
    try {
        $EntraRolesDefaultClassification = Invoke-RestMethod -Method Get -Uri "https://raw.githubusercontent.com/Cloud-Architekt/AzurePrivilegedIAM/refs/heads/main/Classification/Classification_EntraIdDirectoryRoles.json"
    } catch {
        Add-IdGovScopeWarning -Type "DefaultClassificationUnavailable" -Message "Could not load default Entra ID role classification from AzurePrivilegedIAM repository: $($_.Exception.Message). Unclassified directory roles will be treated as ControlPlane (conservative)." -Target "EntraIdDirectoryRoles"
    }
    $DefaultRolesByDefinitionId = @{}
    foreach ($DefaultRole in @($EntraRolesDefaultClassification)) {
        if (-not [string]::IsNullOrEmpty($DefaultRole.RoleId) -and -not $DefaultRolesByDefinitionId.ContainsKey($DefaultRole.RoleId)) {
            $DefaultRolesByDefinitionId[$DefaultRole.RoleId] = $DefaultRole
        }
    }

    # API permission classification lookup ("<resource appId>|<permission displayName>")
    $ApiPermissionsFilePath = Join-Path -Path $FolderClassification -ChildPath "Templates" -AdditionalChildPath "Classification_ApiPermissions.json"
    if (-not (Test-Path -Path $ApiPermissionsFilePath)) {
        $ApiPermissionsFilePath = Join-Path -Path $FolderClassification -ChildPath "Templates" -AdditionalChildPath "Classification_AppRoles.json"
    }
    $ApiPermissionsClassLookup = @{}
    if (Test-Path -Path $ApiPermissionsFilePath) {
        $ApiPermissionsClassification = Get-Content -Path $ApiPermissionsFilePath | ConvertFrom-Json -Depth 10
        foreach ($ApiPermissionsClass in $ApiPermissionsClassification) {
            foreach ($RoleDef in $ApiPermissionsClass.TierLevelDefinition) {
                foreach ($RoleAction in $RoleDef.RoleDefinitionActions) {
                    $key = "$($RoleDef.ResourceAppId)|$($RoleAction)"
                    if (-not $ApiPermissionsClassLookup.ContainsKey($key)) {
                        $ApiPermissionsClassLookup[$key] = [PSCustomObject]@{
                            EAMTierLevelName     = $ApiPermissionsClass.EAMTierLevelName
                            EAMTierLevelTagValue = $ApiPermissionsClass.EAMTierLevelTagValue
                            Service              = $RoleDef.Service
                        }
                    }
                }
            }
        }
    } else {
        Add-IdGovScopeWarning -Type "ClassificationSourceMissing" -Message "API permission classification template not found at $ApiPermissionsFilePath - API permissions in access packages cannot be classified." -Target "ApiPermissions"
    }

    # Normalized ARM scope buckets for AzureResources catalog resources
    $AzureTier0Scopes = @()
    $AzureTier1Scopes = @()
    if ($null -ne $AzureResourceTierScope) {
        $AzureTier0Scopes = @($AzureResourceTierScope.Tier0ResourceScope | Where-Object { $_ -and $_ -ne "/" } | ForEach-Object { $_.ToLower().TrimEnd('/') })
        $AzureTier1Scopes = @($AzureResourceTierScope.Tier1ResourceScope | Where-Object { $_ -and $_ -ne "/" } | ForEach-Object { $_.ToLower().TrimEnd('/') })
    }
    # Scope-match memoization: the same ARM scope typically recurs across catalogs and access
    # packages (e.g. a subscription onboarded to several catalogs), and each containment check is a
    # linear scan over the buckets. Keyed by normalized scope; the buckets are fixed for the whole
    # invocation, so entries can never go stale. Only the match outcome is cached - the per-call
    # detail object (ResourceName/Source/RoleContext) is still built fresh for each resource.
    $AzureScopeMatchCache = @{}
    #endregion

    #region Per-resource classification helpers
    # Returns detail entries: ResourceName, ResourceId, OriginSystem, Source, EAMTier, Reason
    function Get-ClassifiedGroupDetail {
        param([string]$GroupId, [string]$GroupDisplayName, [string]$Source)
        $Details = [System.Collections.Generic.List[psobject]]::new()
        # Iterate the fixed, parameter-declared RBAC system order (not $ClassifiedObjectsByRbac.Keys) - a plain
        # PowerShell hashtable gives no enumeration-order guarantee, so using .Keys here made the resulting
        # Reason list (and thus ScopeReasoning_IdentityGovernance.json) reorder on every run with no data change.
        foreach ($RbacSystem in $FilterClassifiedRbacs) {
            if (-not $ClassifiedObjectsByRbac.ContainsKey($RbacSystem)) { continue }
            $ClassifiedObject = @($ClassifiedObjectsByRbacAndId[$RbacSystem][$GroupId])
            # Also sort the per-object Classification entries deterministically (by tier then Service) rather
            # than trusting their upstream order, for the same reason.
            $ClassItems = @($ClassifiedObject.Classification | Where-Object { $null -ne $_.AdminTierLevel } | Sort-Object AdminTierLevel, Service)
            foreach ($ClassItem in $ClassItems) {
                $Details.Add([PSCustomObject]@{
                        ResourceName = $GroupDisplayName
                        ResourceId   = $GroupId
                        OriginSystem = "AadGroup"
                        Source       = $Source
                        EAMTier      = $TierRankToName[(ConvertTo-IdGovTierRank -TierValue "$($ClassItem.AdminTierLevel)")]
                        Reason       = "Group '$GroupDisplayName' is classified as $($ClassItem.AdminTierLevelName) ($($ClassItem.Service)) in $RbacSystem"
                    }) | Out-Null
            }
        }
        if ($Details.Count -eq 0) {
            $Details.Add([PSCustomObject]@{
                    ResourceName = $GroupDisplayName
                    ResourceId   = $GroupId
                    OriginSystem = "AadGroup"
                    Source       = $Source
                    EAMTier      = "UserAccess"
                    Reason       = "Group '$GroupDisplayName' is not classified in any RBAC system ($($FilterClassifiedRbacs -join ', '))"
                }) | Out-Null
        }
        return $Details
    }

    function Get-ClassifiedDirectoryRoleDetail {
        param([string]$RoleDefinitionId, [string]$RoleDisplayName, [string]$Source)
        $MatchedRole = $EntraIdRolesByDefinitionId[$RoleDefinitionId]
        if ($null -ne $MatchedRole -and $null -ne $MatchedRole.Classification.AdminTierLevel) {
            # The access package resource role scope expansion only carries the scope's generic display
            # name (e.g. "Root"), so prefer the resolved role definition name from the EAM data.
            if (-not [string]::IsNullOrEmpty($MatchedRole.RoleDefinitionName)) { $RoleDisplayName = $MatchedRole.RoleDefinitionName }
            $BestRank = ($MatchedRole.Classification | ForEach-Object { ConvertTo-IdGovTierRank -TierValue "$($_.AdminTierLevel)" } | Measure-Object -Minimum).Minimum
            return [PSCustomObject]@{
                ResourceName = $RoleDisplayName
                ResourceId   = $RoleDefinitionId
                OriginSystem = "DirectoryRole"
                Source       = $Source
                EAMTier      = $TierRankToName[[int]$BestRank]
                Reason       = "Directory role '$RoleDisplayName' is classified as $($TierRankToName[[int]$BestRank]) in EntraID EAM data"
            }
        }
        $MatchedDefaultRole = $DefaultRolesByDefinitionId[$RoleDefinitionId]
        if ($null -ne $MatchedDefaultRole -and $null -ne $MatchedDefaultRole.RolePermissions) {
            if (-not [string]::IsNullOrEmpty($MatchedDefaultRole.RoleName)) { $RoleDisplayName = $MatchedDefaultRole.RoleName }
            $BestRank = ($MatchedDefaultRole.RolePermissions | ForEach-Object { ConvertTo-IdGovTierRank -TierValue "$($_.EAMTierLevelTagValue)" } | Measure-Object -Minimum).Minimum
            return [PSCustomObject]@{
                ResourceName = $RoleDisplayName
                ResourceId   = $RoleDefinitionId
                OriginSystem = "DirectoryRole"
                Source       = $Source
                EAMTier      = $TierRankToName[[int]$BestRank]
                Reason       = "Directory role '$RoleDisplayName' is classified as $($TierRankToName[[int]$BestRank]) in the default classification (AzurePrivilegedIAM)"
            }
        }
        return [PSCustomObject]@{
            ResourceName = $RoleDisplayName
            ResourceId   = $RoleDefinitionId
            OriginSystem = "DirectoryRole"
            Source       = $Source
            EAMTier      = "ControlPlane"
            Reason       = "Directory role '$RoleDisplayName' ($RoleDefinitionId) has no classification - treated as ControlPlane (conservative)"
        }
    }

    function Get-ClassifiedApiPermissionDetail {
        param([string]$ResourceAppId, [string]$ResourceAppDisplayName, [string]$PermissionDisplayName, [string]$PermissionOriginId, [string]$Source)
        $AppRoleMatch = $ApiPermissionsClassLookup["$ResourceAppId|$PermissionDisplayName"]
        if ($null -ne $AppRoleMatch) {
            $Rank = ConvertTo-IdGovTierRank -TierValue "$($AppRoleMatch.EAMTierLevelTagValue)"
            return [PSCustomObject]@{
                ResourceName = $PermissionDisplayName
                ResourceId   = $PermissionOriginId
                OriginSystem = "OAuthApplication"
                Source       = $Source
                EAMTier      = $TierRankToName[$Rank]
                Reason       = "API permission '$PermissionDisplayName' of '$ResourceAppDisplayName' is classified as $($AppRoleMatch.EAMTierLevelName) ($($AppRoleMatch.Service))"
            }
        }
        return [PSCustomObject]@{
            ResourceName = $PermissionDisplayName
            ResourceId   = $PermissionOriginId
            OriginSystem = "OAuthApplication"
            Source       = $Source
            EAMTier      = "ControlPlane"
            Reason       = "API permission '$PermissionDisplayName' of '$ResourceAppDisplayName' has no classification - treated as ControlPlane (conservative)"
        }
    }

    function Get-ClassifiedAadApplicationDetail {
        param([string]$ServicePrincipalObjectId, [string]$ApplicationDisplayName, [string]$Source, [string]$RoleContext)
        $Classifications = @(Get-EntraOpsAadApplicationClassification -ServicePrincipalObjectId $ServicePrincipalObjectId -ClassificationCache $ResourceAppsClassificationCache -DisplayName $ApplicationDisplayName -ContextLabel $Source -WarningMessages $WarningMessages)
        $BestRank = ($Classifications | ForEach-Object { ConvertTo-IdGovTierRank -TierValue $_.AdminTierLevelName } | Measure-Object -Minimum).Minimum
        $TierDrivers = @($Classifications | Where-Object { (ConvertTo-IdGovTierRank -TierValue $_.AdminTierLevelName) -eq $BestRank })
        $Services = @($TierDrivers.Service | Where-Object { $_ } | Select-Object -Unique)
        $RoleSuffix = if ([string]::IsNullOrEmpty($RoleContext)) { "" } else { " (role: $RoleContext)" }
        return [pscustomobject]@{
            ResourceName = $ApplicationDisplayName
            ResourceId   = $ServicePrincipalObjectId
            OriginSystem = "AadApplication"
            Source       = $Source
            EAMTier      = $TierRankToName[[int]$BestRank]
            # The tier is a proxy: it reflects the application's OWN classified permissions
            # (ResourceApps), on the basis that access to an app which itself holds
            # privileged permissions is equivalent exposure - not permissions granted to the assignee.
            Reason       = "Application '$ApplicationDisplayName' is classified as $($TierRankToName[[int]$BestRank]) based on its own ResourceApps permission classification (app access tiered by the application's privilege)$RoleSuffix$(if ($Services.Count -gt 0) { ' (' + ($Services -join ', ') + ')' })"
        }
    }

    function Get-ClassifiedSharePointOnlineDetail {
        param([string]$SiteUrl, [string]$SiteDisplayName, [string]$RoleDisplayName, [string]$RoleOriginId, [string]$Source)
        $Tier = Resolve-EntraOpsSharePointOnlineRoleTier -RoleDisplayName $RoleDisplayName -RoleOriginId $RoleOriginId
        if ($Tier.IsFallback) {
            Add-IdGovScopeWarning -Type "UnknownSharePointRole" -Message "SharePoint role '$RoleDisplayName' (originId '$RoleOriginId') for '$SiteDisplayName' is not recognized - treated as ManagementPlane (conservative)." -Target $SiteUrl
        }
        $Reason = if ($Tier.IsCatalogLevel) {
            "SharePoint site '$SiteDisplayName' is exposed at catalog level - contained roles (including owner-level) are not enumerated here - treated as $($Tier.AdminTierLevelName) (conservative)"
        } else {
            "SharePoint site '$SiteDisplayName' with role '$RoleDisplayName' is classified as $($Tier.AdminTierLevelName)"
        }
        return [pscustomobject]@{
            ResourceName = $SiteDisplayName
            ResourceId   = $SiteUrl
            OriginSystem = "SharePointOnline"
            Source       = $Source
            EAMTier      = $Tier.AdminTierLevelName
            Reason       = $Reason
        }
    }

    function Get-ClassifiedAzureResourceDetail {
        param([string]$ArmScopeId, [string]$ResourceDisplayName, [string]$Source, [string]$RoleContext)
        $RoleSuffix = if ([string]::IsNullOrEmpty($RoleContext)) { "" } else { " (role: $RoleContext)" }
        if ($null -eq $AzureResourceTierScope) {
            return [PSCustomObject]@{
                ResourceName = $ResourceDisplayName
                ResourceId   = $ArmScopeId
                OriginSystem = "AzureResources"
                Source       = $Source
                EAMTier      = "ControlPlane"
                Reason       = "Azure scope '$ArmScopeId' could not be evaluated (no Azure resource tier scope resolved) - treated as ControlPlane (conservative)$RoleSuffix"
            }
        }
        $NormalizedScope = "$ArmScopeId".ToLower().TrimEnd('/')
        # Shared containment convention (Find-EntraOpsAzureScopeContainmentMatch), identical to the
        # EAM-side Resolve-EntraOpsAzureScopeReasoningTier so both outputs agree for the same catalog.
        # The match outcome per normalized scope is memoized in $AzureScopeMatchCache (see its
        # declaration for the invariants); the detail object stays per-call.
        $ScopeMatch = $AzureScopeMatchCache[$NormalizedScope]
        if ($null -eq $ScopeMatch) {
            $Tier0Match = Find-EntraOpsAzureScopeContainmentMatch -Scope $NormalizedScope -BucketedScopes $AzureTier0Scopes
            if ($null -ne $Tier0Match) {
                $ScopeMatch = [PSCustomObject]@{ EAMTier = "ControlPlane"; TierLabel = "Tier0 (ControlPlane)"; MatchedScope = $Tier0Match }
            } else {
                $Tier1Match = Find-EntraOpsAzureScopeContainmentMatch -Scope $NormalizedScope -BucketedScopes $AzureTier1Scopes
                if ($null -ne $Tier1Match) {
                    $ScopeMatch = [PSCustomObject]@{ EAMTier = "ManagementPlane"; TierLabel = "Tier1 (ManagementPlane)"; MatchedScope = $Tier1Match }
                } else {
                    $ScopeMatch = [PSCustomObject]@{ EAMTier = "UserAccess"; TierLabel = $null; MatchedScope = $null }
                }
            }
            $AzureScopeMatchCache[$NormalizedScope] = $ScopeMatch
        }
        if ($null -ne $ScopeMatch.MatchedScope) {
            return [PSCustomObject]@{
                ResourceName = $ResourceDisplayName
                ResourceId   = $ArmScopeId
                OriginSystem = "AzureResources"
                Source       = $Source
                EAMTier      = $ScopeMatch.EAMTier
                Reason       = "Azure scope '$ArmScopeId' equals, contains or lies within $($ScopeMatch.TierLabel) resource scope '$($ScopeMatch.MatchedScope)'$RoleSuffix"
            }
        }
        return [PSCustomObject]@{
            ResourceName = $ResourceDisplayName
            ResourceId   = $ArmScopeId
            OriginSystem = "AzureResources"
            Source       = $Source
            EAMTier      = "UserAccess"
            Reason       = "Azure scope '$ArmScopeId' has no hierarchy overlap with any Tier0/Tier1 classified resource scope$RoleSuffix"
        }
    }

    # Classify a single accessPackageResourceRoleScope (expanded with role and scope)
    function Get-ClassifiedResourceRoleScopeDetail {
        param([psobject]$ResourceRoleScope, [string]$Source)
        $ResourceRole = $ResourceRoleScope.accessPackageResourceRole
        $ResourceScope = $ResourceRoleScope.accessPackageResourceScope
        $IsPending = ($null -ne $ResourceRoleScope.PSObject.Properties['isPending'] -and $ResourceRoleScope.isPending -eq $true)

        $Details = switch ($ResourceScope.originSystem) {
            'AadGroup' { Get-ClassifiedGroupDetail -GroupId $ResourceScope.originId -GroupDisplayName $ResourceScope.displayName -Source $Source }
            'DirectoryRole' { Get-ClassifiedDirectoryRoleDetail -RoleDefinitionId $ResourceScope.originId -RoleDisplayName $ResourceScope.displayName -Source $Source }
            'OAuthApplication' { Get-ClassifiedApiPermissionDetail -ResourceAppId $ResourceScope.originId -ResourceAppDisplayName $ResourceScope.displayName -PermissionDisplayName $ResourceRole.displayName -PermissionOriginId $ResourceRole.originId -Source $Source }
            'AadApplication' { Get-ClassifiedAadApplicationDetail -ServicePrincipalObjectId $ResourceScope.originId -ApplicationDisplayName $ResourceScope.displayName -Source $Source -RoleContext $ResourceRole.displayName }
            'SharePointOnline' { Get-ClassifiedSharePointOnlineDetail -SiteUrl $ResourceScope.originId -SiteDisplayName $ResourceScope.displayName -RoleDisplayName $ResourceRole.displayName -RoleOriginId "$($ResourceRole.originId)" -Source $Source }
            'AzureResources' { Get-ClassifiedAzureResourceDetail -ArmScopeId $ResourceScope.originId -ResourceDisplayName $ResourceScope.displayName -Source $Source -RoleContext $ResourceRole.displayName }
            default {
                Add-IdGovScopeWarning -Type "UnknownOriginSystem" -Message "Origin system $($ResourceScope.originSystem) not supported for Identity Governance scope classification - treated as ControlPlane (conservative)." -Target "$($ResourceScope.originSystem)"
                [PSCustomObject]@{
                    ResourceName = $ResourceScope.displayName
                    ResourceId   = $ResourceScope.originId
                    OriginSystem = $ResourceScope.originSystem
                    Source       = $Source
                    EAMTier      = "ControlPlane"
                    Reason       = "Origin system '$($ResourceScope.originSystem)' is not supported for classification - treated as ControlPlane (conservative)"
                }
            }
        }
        if ($IsPending) {
            foreach ($Detail in @($Details)) {
                $Detail.Reason = "$($Detail.Reason) [pending approval]"
            }
        }
        return @($Details)
    }
    #endregion

    #region Enumerate and classify catalogs and access packages
    $ScopeClassifications = [System.Collections.Generic.List[psobject]]::new()

    try {
        $AllCatalogs = @(Invoke-EntraOpsMsGraphQuery -Uri "/beta/identityGovernance/entitlementManagement/accessPackageCatalogs?`$select=id,displayName" -OutputType PSObject)
    } catch {
        Add-IdGovScopeWarning -Type "CatalogEnumerationError" -Message "Failed to enumerate access package catalogs: $($_.Exception.Message). Identity Governance scope classification will keep the conservative Tier0 default for all scopes." -Target "AccessPackageCatalogs"
        return @()
    }

    $AccessPackageHydrationCache = @{}
    foreach ($Catalog in $AllCatalogs) {
        $CatalogScopeId = "/AccessPackageCatalog/$($Catalog.id)"
        $CatalogResourceDetails = [System.Collections.Generic.List[psobject]]::new()

        try {
            $CatalogResources = @(Invoke-EntraOpsMsGraphQuery -Uri "/beta/identityGovernance/entitlementManagement/accessPackageCatalogs/$($Catalog.id)/accessPackageResources?`$expand=accessPackageResourceScopes,accessPackageResourceRoles" -ConsistencyLevel "eventual" -OutputType PSObject | Where-Object { $null -ne $_.originId })
        } catch {
            Add-IdGovScopeWarning -Type "CatalogResolutionError" -Message "Error resolving resources of catalog $($Catalog.id): $($_.Exception.Message) - catalog is treated as ControlPlane (conservative)." -Target $Catalog.id
            $CatalogResources = $null
        }
        try {
            # Neither accessPackageCatalogs/{id}/accessPackages (invalid navigation path, 404) nor
            # the top-level accessPackages collection with $filter=catalogId/catalog-id eq (Graph
            # returns InvalidFilter - $filter isn't supported on that collection at all) work.
            # Expanding accessPackages directly on the catalog's own single-entity GET does work -
            # the response is the CATALOG entity with access package stubs nested under
            # .accessPackages (not a flat list), unwrapped here. Also deliberately no nested $expand
            # of accessPackageResourceRoleScopes - each access package's resource role scopes are
            # hydrated individually below instead.
            $CatalogWithAccessPackages = Invoke-EntraOpsMsGraphQuery -Uri "/beta/identityGovernance/entitlementManagement/accessPackageCatalogs('$($Catalog.id)')?`$expand=accessPackages" -ConsistencyLevel "eventual" -OutputType PSObject
            $CatalogAccessPackageStubs = @(@($CatalogWithAccessPackages) | Select-Object -First 1 -ExpandProperty accessPackages -ErrorAction SilentlyContinue)
            $CatalogAccessPackages = @(
                foreach ($AccessPackageStub in $CatalogAccessPackageStubs) {
                    if ($null -eq $AccessPackageStub -or [string]::IsNullOrEmpty($AccessPackageStub.id)) {
                        continue
                    }
                    if (-not $AccessPackageHydrationCache.ContainsKey($AccessPackageStub.id)) {
                        $AccessPackageHydrationCache[$AccessPackageStub.id] = Invoke-EntraOpsMsGraphQuery -Uri "/beta/identityGovernance/entitlementManagement/accessPackages/$($AccessPackageStub.id)?`$expand=accessPackageResourceRoleScopes(`$expand=accessPackageResourceRole,accessPackageResourceScope)" -ConsistencyLevel "eventual" -OutputType PSObject
                    }
                    $AccessPackageHydrationCache[$AccessPackageStub.id]
                }
            )
        } catch {
            Add-IdGovScopeWarning -Type "AccessPackageResolutionError" -Message "Error resolving access packages of catalog $($Catalog.id): $($_.Exception.Message) - catalog is treated as ControlPlane (conservative)." -Target $Catalog.id
            $CatalogAccessPackages = $null
        }

        # A failed Graph resolution must not silently downgrade the catalog to UserAccess
        $CatalogResolutionFailed = ($null -eq $CatalogResources -or $null -eq $CatalogAccessPackages)

        # Catalog-level resources: groups, directory roles and Azure resources. API permissions are
        # intentionally NOT taken from the catalog resource inventory: the catalog-level
        # accessPackageResourceRoles enumeration for API resources returns permissions across catalogs
        # (Microsoft Graph beta bug), so they are classified from the access packages below instead.
        foreach ($CatalogResource in @($CatalogResources)) {
            switch ($CatalogResource.originSystem) {
                'AadGroup' {
                    foreach ($Detail in @(Get-ClassifiedGroupDetail -GroupId $CatalogResource.originId -GroupDisplayName $CatalogResource.displayName -Source "CatalogResource")) {
                        $CatalogResourceDetails.Add($Detail) | Out-Null
                    }
                }
                'DirectoryRole' {
                    if ($CatalogResource.accessPackageResourceScopes.isRootScope -ne $true) {
                        Add-IdGovScopeWarning -Type "ScopeLimitation" -Message "Assigned catalog resource scope of directory role '$($CatalogResource.displayName)' is not root scope - directory roles are currently only supported on root scope!" -Target $CatalogResource.displayName
                    }
                    $CatalogResourceDetails.Add((Get-ClassifiedDirectoryRoleDetail -RoleDefinitionId $CatalogResource.originId -RoleDisplayName $CatalogResource.displayName -Source "CatalogResource")) | Out-Null
                }
                'OAuthApplication' {
                    Write-Verbose "Skipping catalog-level API permission enumeration of '$($CatalogResource.displayName)' in catalog $($Catalog.id) (unreliable across catalogs) - classified from access package resource role scopes instead."
                }
                'AadApplication' {
                    $CatalogResourceDetails.Add((Get-ClassifiedAadApplicationDetail -ServicePrincipalObjectId $CatalogResource.originId -ApplicationDisplayName $CatalogResource.displayName -Source "CatalogResource" -RoleContext $null)) | Out-Null
                }
                'SharePointOnline' {
                    # Catalog-level entry carries no role - the resolver returns the conservative
                    # ManagementPlane catalog-level result without an unknown-role warning.
                    $CatalogResourceDetails.Add((Get-ClassifiedSharePointOnlineDetail -SiteUrl $CatalogResource.originId -SiteDisplayName $CatalogResource.displayName -RoleDisplayName "" -RoleOriginId "" -Source "CatalogResource")) | Out-Null
                }
                'AzureResources' {
                    $CatalogResourceDetails.Add((Get-ClassifiedAzureResourceDetail -ArmScopeId $CatalogResource.originId -ResourceDisplayName $CatalogResource.displayName -Source "CatalogResource" -RoleContext $null)) | Out-Null
                }
                default {
                    Add-IdGovScopeWarning -Type "UnknownOriginSystem" -Message "Origin system $($CatalogResource.originSystem) not supported for Identity Governance scope classification - treated as ControlPlane (conservative)." -Target "$($CatalogResource.originSystem)"
                    $CatalogResourceDetails.Add([PSCustomObject]@{
                            ResourceName = $CatalogResource.displayName
                            ResourceId   = $CatalogResource.originId
                            OriginSystem = $CatalogResource.originSystem
                            Source       = "CatalogResource"
                            EAMTier      = "ControlPlane"
                            Reason       = "Origin system '$($CatalogResource.originSystem)' is not supported for classification - treated as ControlPlane (conservative)"
                        }) | Out-Null
                }
            }
        }

        # Access packages: classify each access package scope AND feed API permissions (and any other
        # resource role scopes) into the catalog tier
        foreach ($AccessPackage in @($CatalogAccessPackages | Where-Object { $null -ne $_ })) {
            $AccessPackageScopeId = "/AccessPackage/$($AccessPackage.id)"
            $AccessPackageResourceDetails = [System.Collections.Generic.List[psobject]]::new()
            $ResourceRoleScopes = @($AccessPackage.accessPackageResourceRoleScopes | Where-Object { $null -ne $_.accessPackageResourceRole -and $null -ne $_.accessPackageResourceScope })
            foreach ($ResourceRoleScope in $ResourceRoleScopes) {
                foreach ($Detail in @(Get-ClassifiedResourceRoleScopeDetail -ResourceRoleScope $ResourceRoleScope -Source "AccessPackageResource")) {
                    $AccessPackageResourceDetails.Add($Detail) | Out-Null
                    # API permissions only count towards the catalog tier via access packages (see above);
                    # groups/roles/Azure scopes are already covered by the catalog resource inventory.
                    if ($Detail.OriginSystem -eq "OAuthApplication") {
                        $CatalogResourceDetails.Add($Detail) | Out-Null
                    }
                }
            }

            $ApBestRank = 3   # UserAccess rank - scopes without any assigned resource are UserAccess (see .DESCRIPTION)
            $ApReason = "No resource role scopes are assigned to this access package"
            $ApSource = "AccessPackageResource"
            if ($AccessPackageResourceDetails.Count -gt 0) {
                $ApBestRank = ($AccessPackageResourceDetails | ForEach-Object { ConvertTo-IdGovTierRank -TierValue $_.EAMTier } | Measure-Object -Minimum).Minimum
                $ApTierDrivers = @($AccessPackageResourceDetails | Where-Object { (ConvertTo-IdGovTierRank -TierValue $_.EAMTier) -eq $ApBestRank })
                $ApReason = (@($ApTierDrivers | Select-Object -First 3 | ForEach-Object { $_.Reason }) -join "; ")
                if ($ApTierDrivers.Count -gt 3) { $ApReason += "; (+$($ApTierDrivers.Count - 3) more)" }
            }
            $ScopeClassifications.Add([PSCustomObject]@{
                    ScopeName           = $AccessPackage.displayName
                    ScopeId             = $AccessPackageScopeId
                    ScopeType           = "AccessPackage"
                    CatalogId           = $Catalog.id
                    CatalogDisplayName  = $Catalog.displayName
                    Source              = $ApSource
                    EAMTier             = $TierRankToName[[int]$ApBestRank]
                    ResultingScope      = $TierRankToScope[[int]$ApBestRank]
                    Reason              = $ApReason
                    ClassifiedResources = @($AccessPackageResourceDetails)
                }) | Out-Null
        }

        $CatalogBestRank = 3   # UserAccess rank - scopes without any assigned resource are UserAccess (see .DESCRIPTION)
        $CatalogReason = "No resources are assigned to this catalog or its access packages"
        if ($CatalogResolutionFailed) {
            $CatalogBestRank = 0
            $CatalogReason = "Catalog resources or access packages could not be resolved - treated as ControlPlane (conservative)"
        } elseif ($CatalogResourceDetails.Count -gt 0) {
            $CatalogBestRank = ($CatalogResourceDetails | ForEach-Object { ConvertTo-IdGovTierRank -TierValue $_.EAMTier } | Measure-Object -Minimum).Minimum
            $CatalogTierDrivers = @($CatalogResourceDetails | Where-Object { (ConvertTo-IdGovTierRank -TierValue $_.EAMTier) -eq $CatalogBestRank })
            $CatalogReason = (@($CatalogTierDrivers | Select-Object -First 3 | ForEach-Object { $_.Reason }) -join "; ")
            if ($CatalogTierDrivers.Count -gt 3) { $CatalogReason += "; (+$($CatalogTierDrivers.Count - 3) more)" }
        }
        $ScopeClassifications.Add([PSCustomObject]@{
                ScopeName           = $Catalog.displayName
                ScopeId             = $CatalogScopeId
                ScopeType           = "AccessPackageCatalog"
                CatalogId           = $Catalog.id
                CatalogDisplayName  = $Catalog.displayName
                Source              = "CatalogResource"
                EAMTier             = $TierRankToName[[int]$CatalogBestRank]
                ResultingScope      = $TierRankToScope[[int]$CatalogBestRank]
                Reason              = $CatalogReason
                ClassifiedResources = @($CatalogResourceDetails)
            }) | Out-Null
    }
    #endregion

    # Deliberately NOT using ", @($ScopeClassifications)" here: the caller wraps this call in its own
    # @(...) (Update-EntraOpsClassificationControlPlaneScope.ps1), and a leading comma would emit the
    # whole list as a single pipeline object, which the caller's @() then wraps AGAIN into a 1-element
    # outer array containing the inner array - collapsing every entry into one item and breaking
    # property access (e.g. Where-Object/Select-Object -ExpandProperty) on the individual objects.
    return $ScopeClassifications
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCJzYtC27JMuXQZ
# R2VwzHSvj6dqlvazg/p+FkLK8fF//qCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# 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
# ARUwLwYJKoZIhvcNAQkEMSIEINgtM3OVlq5JyJNXB1xTMzJ1ofcsiTrN/rFpP4Br
# vHQHMA0GCSqGSIb3DQEBAQUABIIBgEbz7YhsX4hpnS4mfSttZZJlgq03WNsalb71
# sXFms6gIgbgtl8wSBIA7EWUQWx6Pi1aJi9CpejsQGZqNIwiJWuAClkLqkYrtyOUW
# 93Rf4YnfTdFngEDFdJsuDVVWrj6MToJS8xEd/1/+ak6OZ0C+pHmjR0/rpCZxod7f
# A4vuYVCzJ22DsbvY+DSEFhTeWhPddLdUq3nT3Moyj6rMwPi7bfzP2LXVWcgihWw4
# oLk1f8gkThxfhF6+7lEWl/5/QpFkEahYgrDH5t41H9GBuDPTAAJdMG2+Xm0iv1tf
# GOhtslwSa9K6YzqCBLpPaAUykvLGOmhm5EFblg2ryATdTeNNjmp4N+pm2xhWIVJp
# YLvd7vFvcCaRJdtpT/XcmyZEp53u+RiaBJMW+56lkyPd/ljxmzuyDaLd2Whl65qA
# RtryhAEz7VOm2b2Oy37Y93dfysTWaO+VoG2ZhqpMoM6TS2t+CyvejAud3ophVuFY
# 1Rk567z/YfewVaq70ubB7PpVxp0uUqGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTM5NDBaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDCOUPlBlF2q/xosSqJjF4JedBopOHekvXnA
# dfD24YvmBRndCqvhbuan8WcJN0BsQmEwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAdsfCQe3kgcO1549cr17BnLWNoZFL+nz286davvxtR3X8OAK2
# D4JKL33vhaL8kABt8qb6fDUh7Gx2MKAMVlC6oJvsvUEi0zjIXvaz70yg727DOhsU
# LMafAVIAOWRobj7X6m4m/D9+ejFuEuuPPYg806NvKr0lqYgunFSyZUNf3qqtzGrx
# 4yX8SJx57p+azs/zCbX3/MQVuXLGNqHthFj4VyM3bj5wruPiXhKBcApkC5b6FYer
# nv5AdJK8YCCSH+tkCAw5chUPskIAGT2ngUYrBDxM1rRdngXe7s6Ij1LIJavLxelz
# cZtCmi2gQU0htIY/8SUaaJ8p0zvsHO8/lJbhuJURaSOzhpGjXg+PBtVUWYzsMYKx
# K7eKf/JjhX5eVlH3Y9bqqzOYwchRCWKectxOpK31Dw8ChpXcZZo6ceSbzwJf2CGA
# Mzu6OCKfQ0Mh0pu0Vt3aadbGQKd4r60QfFRkpWMsfOw/0foWzFIF09UYkTu+bp7R
# IoCsY52BHMZOza9mp/LjYMAK6qIlnDOlKf9WNmH90443/A4lZx1IQgdCqajx649c
# Zyoy5GOGb4NnHbsTXkJhD4PtEW8mxdaIU1XvMfZmZ9RSaZOAv1xlg7kA4hA/mPzD
# dxjTSLcueWENhRxU6WaIrPt6nb/UO1UB9gZW3UXHmnmzHcrI8GoGh2Vr/2g=
# SIG # End signature block