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 |