Public/TenantGovernance/Save-EntraOpsTenantGovernanceSnapshotJson.ps1
|
<# .SYNOPSIS Create an EntraOps Tenant Governance snapshot and persist each captured resource as an individual JSON file tracked in Git. .DESCRIPTION Wraps Get-EntraOpsTenantGovernanceSnapshot to capture the configured Microsoft Entra resources and stores each captured resource as its own file under <EntraOpsBaseFolder>/TenantGovernance/Snapshots, organized in subfolders: <ExportFolder>/<resourceType>/<first segment of displayName>/<remaining displayName>.json For example, a resource with resourceType "microsoft.entra.conditionalaccesspolicy" and displayName "AADConditionalAccessPolicy-Workplace 3 - Allow non-compliant Browser with MFA and Session Controls" is stored at: <ExportFolder>/microsoft.entra.conditionalaccesspolicy/AADConditionalAccessPolicy/Workplace 3 - Allow non-compliant Browser with MFA and Session Controls.json The displayName is split on its first "-" only: everything before it becomes the subfolder, everything after it (which may itself contain further "-" characters) becomes the file name. Every path segment is normalized (see ConvertTo-EntraOpsSafeFilePathSegment) so the resulting files can be created, committed, and checked out on Windows, Linux, and macOS alike, regardless of the platform the snapshot was collected on. Resource-type folders are staged and replaced as a unit, so resources removed from the tenant do not remain as stale files. Historical point-in-time versions aren't kept as separate files but are tracked through the repository's Git commit history instead, the same approach used for PrivilegedEAM data and Privilege History (see Reports/PrivilegeHistory/README.md). Snapshot metadata (SnapshotId, DisplayName, CapturedDateTime, ResourcesToInclude, ResourceCount, resource-type counts, and completion status) is persisted in <ExportFolder>/.SnapshotManifest.json and also shown as a summary in the command output. The latest attempt is recorded separately in <ExportFolder>/.LastAttemptManifest.json. When Graph reports a partiallySuccessful job, resource types without Graph errors are published from staging. A type whose only errors are per-resource export failures (for example Request_ResourceNotFound for one access package policy) still returns every resource that did export: those are published, and the previously published file of each resource the attempt did not return is retained so a failed export never shows up as a deleted resource in Git history. Such types are marked PublishedWithErrors with their RetainedResourceCount. Types whose backing workload could not be reached (ConnectionError) or that returned no resource at all retain their prior files and are marked PreservedStale with their published and attempted snapshot Ids. If Graph provides no parseable per-type errors, no downloaded types are published; all existing files are preserved as stale and the operation completes with a warning instead of failing the workflow. Before creating the snapshot, prerequisites (the "ConfigurationMonitoring.Read.All" permission and the Microsoft Tenant Configuration Management service principal) are validated automatically. If they are not met, run Register-EntraOpsTenantGovernanceServicePrincipal and/or New-EntraOpsWorkloadIdentity first (see Test-EntraOpsTenantGovernancePrerequisite to troubleshoot). Large snapshots can take a while to complete on the Microsoft Graph side. Use -Operation Start to create a job and persist its Id, then -Operation Collect in a later run to check it once and download completed results. Use -Operation RunAndWait for an interactive create-and-wait execution. .PARAMETER ExportFolder Folder where the per-resource snapshot files are stored. Default is <EntraOpsBaseFolder>/TenantGovernance/Snapshots. .PARAMETER ResourcesToInclude Array of Microsoft Entra resource types to include in the snapshot. Defaults to the recommended tenant governance resource set. .PARAMETER SnapshotDisplayNamePrefix Prefix used for the snapshot's display name on the Microsoft Graph side (visible in the admin center / when listing snapshot jobs via Graph). Default is "EntraOps TG". The Microsoft Graph UTCM API only allows alphabets, numbers, and spaces in the resulting display name (any other characters, e.g. hyphens, are stripped automatically) and enforces a length between 8 and 32 characters (longer names are truncated automatically). Purely cosmetic - it has no effect on the local file name or on how the snapshot is retrieved. .PARAMETER TimeoutInSeconds Maximum time (in seconds) to wait for the snapshot job to complete. Default is 900 (15 minutes). If the wait expires, the still-running job Id is persisted as a pending job so it can be retrieved later with -Operation Collect. .PARAMETER Operation Explicit snapshot operation: - Start: create a new job, persist its Id, and return without checking or downloading it. - Collect: check the persisted job once; download it only when complete. Never creates a job. - RunAndWait: create a new job and poll until complete. Intended for manual use. Start and RunAndWait refuse to proceed while a pending job exists. Collect is a successful no-op when no job is pending, which makes scheduled collection retries safe after an earlier retry succeeds. .PARAMETER SkipWaitForCompletion Deprecated compatibility alias for -Operation Start. Do not combine it with -Operation. .PARAMETER SkipPrerequisiteCheck Skip the automatic prerequisite validation (Test-EntraOpsTenantGovernancePrerequisite) before creating the snapshot job. Default is $false. .PARAMETER SnapshotResourceFileNaming Controls the file name used for each persisted resource file (the folder structure - resourceType then the first displayName segment - is unaffected). Possible values: - "DisplayName" (default): the file is named after the remainder of the resource's displayName (everything after its first "-"), e.g. "Legacy 1 - Block legacy auth.json". Human-readable, but the file is effectively renamed/re-created if the resource's displayName changes. - "ResourceId": the file is named after the resource's underlying Id (typically a GUID), e.g. "e9f699a0-fb3f-48da-84c6-6351163b2109.json". Stable across renames, so Git history tracks changes to the same resource as diffs to the same file instead of a rename. Falls back to DisplayName-based naming for resource types that don't expose an Id. .EXAMPLE Save-EntraOpsTenantGovernanceSnapshotJson .EXAMPLE Save a snapshot of Conditional Access and Named Locations only. Save-EntraOpsTenantGovernanceSnapshotJson -ResourcesToInclude @("microsoft.entra.conditionalAccessPolicy","microsoft.entra.namedLocationPolicy") .EXAMPLE Start a snapshot job without waiting (e.g. a scheduled run at 06:00), then check and save it in a later, separate run (e.g. scheduled collector runs at 07:00, 07:30, and 08:00). Save-EntraOpsTenantGovernanceSnapshotJson -Operation Start # ... in a separate, later run ... Save-EntraOpsTenantGovernanceSnapshotJson -Operation Collect .EXAMPLE Create a snapshot and wait for it during a manual run. Save-EntraOpsTenantGovernanceSnapshotJson -Operation RunAndWait -TimeoutInSeconds 3300 #> function Save-EntraOpsTenantGovernanceSnapshotJson { [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [System.String]$ExportFolder = "$EntraOpsBaseFolder/TenantGovernance/Snapshots" , [Parameter(Mandatory = $false)] [Array]$ResourcesToInclude = (Get-EntraOpsTenantGovernanceResourceDefinition).DefaultResources , [Parameter(Mandatory = $false)] [System.String]$SnapshotDisplayNamePrefix = "EntraOps TG" , [Parameter(Mandatory = $false)] [System.Int32]$TimeoutInSeconds = 900 , [Parameter(Mandatory = $false)] [ValidateSet('Start', 'Collect', 'RunAndWait')] [System.String]$Operation = 'RunAndWait' , [Parameter(Mandatory = $false)] [switch]$SkipWaitForCompletion , [Parameter(Mandatory = $false)] [switch]$SkipPrerequisiteCheck , [Parameter(Mandatory = $false)] [ValidateSet('DisplayName', 'ResourceId')] [System.String]$SnapshotResourceFileNaming = 'DisplayName' ) $ErrorActionPreference = "Stop" # --- Path safety: ensure ExportFolder is under the expected base directory --- $ResolvedExportFolder = [System.IO.Path]::GetFullPath($ExportFolder) $ResolvedBaseFolder = [System.IO.Path]::GetFullPath($EntraOpsBaseFolder) if (-not (Test-EntraOpsPathWithinRoot -Path $ExportFolder -Root $EntraOpsBaseFolder)) { throw "Security check failed: ExportFolder '$ResolvedExportFolder' is not under the expected base directory '$ResolvedBaseFolder'. Aborting to prevent accidental data loss." } if (-not (Test-Path -Path $ExportFolder)) { New-Item -Path $ExportFolder -ItemType Directory -Force | Out-Null } $PendingJobFilePath = Join-Path -Path $ExportFolder -ChildPath ".PendingSnapshotJob.json" $SnapshotManifestFilePath = Join-Path -Path $ExportFolder -ChildPath ".SnapshotManifest.json" $LastAttemptManifestFilePath = Join-Path -Path $ExportFolder -ChildPath ".LastAttemptManifest.json" if ($SkipWaitForCompletion) { if ($PSBoundParameters.ContainsKey('Operation')) { throw "SkipWaitForCompletion is a deprecated alias for -Operation Start and cannot be combined with -Operation." } Write-Warning "SkipWaitForCompletion is deprecated. Use -Operation Start instead." $Operation = 'Start' } #region Read pending job state $PendingJob = $null if (Test-Path -Path $PendingJobFilePath -ErrorAction SilentlyContinue) { try { $PendingJob = Get-Content -Path $PendingJobFilePath -Raw | ConvertFrom-Json } catch { throw "Failed to read pending snapshot job state file $PendingJobFilePath. Refusing to create or collect a snapshot until the state is repaired. Error: $_" } } #endregion #region Execute the requested state-machine operation $HasPendingJob = $PendingJob -and -not [string]::IsNullOrWhiteSpace($PendingJob.SnapshotJobId) if ($Operation -eq 'Collect') { if (-not $HasPendingJob) { Write-Output "No pending Tenant Governance snapshot job exists. Nothing to collect." return [PSCustomObject]@{ Operation = 'Collect'; Status = 'NoPendingJob' } } Write-Output "Checking pending snapshot job $($PendingJob.SnapshotJobId) started at $($PendingJob.CreatedDateTime)..." $Snapshot = Get-EntraOpsTenantGovernanceSnapshot -SnapshotJobId $PendingJob.SnapshotJobId -ResourcesToInclude @($PendingJob.ResourcesToInclude) -SkipWaitForCompletion -SkipPrerequisiteCheck:$SkipPrerequisiteCheck } else { if ($HasPendingJob) { throw "Pending snapshot job $($PendingJob.SnapshotJobId) already exists. Run -Operation Collect before starting another job." } $Timestamp = (Get-Date).ToUniversalTime() $SnapshotDisplayName = "$($SnapshotDisplayNamePrefix) $($Timestamp.ToString('yyyyMMddHHmmss'))" $SnapshotDisplayName = ($SnapshotDisplayName -replace '[^a-zA-Z0-9 ]', ' ') -replace '\s+', ' ' $SnapshotDisplayName = $SnapshotDisplayName.Trim() if ($SnapshotDisplayName.Length -gt 32) { $SnapshotDisplayName = $SnapshotDisplayName.Substring(0, 32).TrimEnd() } if ($SnapshotDisplayName.Length -lt 8) { $SnapshotDisplayName = $SnapshotDisplayName.PadRight(8, '0') } $Snapshot = Get-EntraOpsTenantGovernanceSnapshot -ResourcesToInclude $ResourcesToInclude -SnapshotDisplayName $SnapshotDisplayName -TimeoutInSeconds $TimeoutInSeconds -SkipWaitForCompletion:($Operation -eq 'Start') -SkipPrerequisiteCheck:$SkipPrerequisiteCheck } #endregion #region If the snapshot job is still pending, persist its state and return without touching the main snapshot file if ($Snapshot.Status -ne "Completed") { $PendingJobOutputObject = [ordered]@{ SnapshotJobId = $Snapshot.SnapshotId SnapshotDisplayName = $Snapshot.DisplayName CreatedDateTime = if ($HasPendingJob) { $PendingJob.CreatedDateTime } else { $Snapshot.CreatedDateTime } ResourcesToInclude = @($Snapshot.ResourcesToInclude) LastCheckedDateTime = (Get-Date).ToUniversalTime().ToString('o') LastKnownStatus = $Snapshot.Status LastPollingError = $Snapshot.PollingError } $PendingJobOutputObject | ConvertTo-Json -Depth 10 | Out-File -Path $PendingJobFilePath -Encoding utf8 Write-Output "Snapshot job $($Snapshot.SnapshotId) is $($Snapshot.Status). State saved to $PendingJobFilePath. Collect it later with Save-EntraOpsTenantGovernanceSnapshotJson -Operation Collect." return $PendingJobOutputObject } #endregion $SnapshotJobStatus = if ([string]::IsNullOrWhiteSpace($Snapshot.SnapshotJobStatus)) { 'completed' } else { $Snapshot.SnapshotJobStatus } $ParsedErrorDetails = @(@($Snapshot.ErrorDetails) | Where-Object { $_ } | ConvertFrom-EntraOpsTenantGovernanceSnapshotErrorDetail) $SnapshotDiagnostics = @( $ParsedErrorDetails | Group-Object ResourceType, ErrorCategory, ErrorCode, NormalizedMessage | ForEach-Object { $First = $_.Group[0] [PSCustomObject][ordered]@{ ResourceType = if ($First.ResourceType) { $First.ResourceType.ToLowerInvariant() } else { $null } ErrorCategory = $First.ErrorCategory ErrorCode = $First.ErrorCode Occurrences = $_.Count Message = $First.NormalizedMessage RemediationHint = switch ($First.ErrorCategory) { 'ConnectionError' { 'Retry the snapshot and verify the backing workload/API is available.' } 'ExportError' { 'Review the resource-type permission, referenced object and UTCM support, then retry.' } default { 'Inspect the protected workflow diagnostics or query the snapshot job with Get-EntraOpsTenantGovernanceSnapshotReport.' } } } } ) [string[]]$ResourceTypesWithErrors = @( $ParsedErrorDetails | ForEach-Object { $_.ResourceType } | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique ) # Graph does not guarantee errorDetails order. Keep the manifest's StaleResourceTypes stable # when the same resource types fail in a different order on a later run. [Array]::Sort($ResourceTypesWithErrors, [StringComparer]::Ordinal) $RequestedResourceTypes = @( @($Snapshot.ResourcesToInclude) | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique ) $UnorderedAttemptResourceTypeCounts = @{} foreach ($Resource in @($Snapshot.Resources)) { $ResourceType = if ($Resource.resourceType) { $Resource.resourceType.ToLowerInvariant() } else { 'unknown' } if (-not $UnorderedAttemptResourceTypeCounts.ContainsKey($ResourceType)) { $UnorderedAttemptResourceTypeCounts[$ResourceType] = 0 } $UnorderedAttemptResourceTypeCounts[$ResourceType]++ } # UTCM reports per-resource export failures at the type level while still returning every # resource of that type that did export. Only a type whose errors are all ExportError and that # returned at least one resource qualifies; a ConnectionError means the whole workload was # unreachable and the returned set cannot be trusted. [string[]]$ResourceTypesWithRetainedResources = @( foreach ($ResourceType in $ResourceTypesWithErrors) { $Categories = @($ParsedErrorDetails | Where-Object { $_.ResourceType -and $_.ResourceType.ToLowerInvariant() -eq $ResourceType } | ForEach-Object { $_.ErrorCategory } | Select-Object -Unique) if ($Categories.Count -gt 0 -and @($Categories | Where-Object { $_ -ne 'ExportError' }).Count -eq 0 -and [int]$UnorderedAttemptResourceTypeCounts[$ResourceType] -gt 0) { $ResourceType } } ) [string[]]$StaleResourceTypes = @($ResourceTypesWithErrors | Where-Object { $ResourceTypesWithRetainedResources -notcontains $_ }) if ($SnapshotJobStatus -eq 'partiallySuccessful' -and $ResourceTypesWithErrors.Count -eq 0) { $PreviousManifest = $null if (Test-Path -Path $SnapshotManifestFilePath -ErrorAction SilentlyContinue) { try { $PreviousManifest = Get-Content -Path $SnapshotManifestFilePath -Raw | ConvertFrom-Json -Depth 10 } catch { Write-Warning "The previous snapshot manifest could not be read. Existing resource files remain preserved, but their source snapshot cannot be identified. Error: $_" } } $PreviousResourceTypeStates = @{} foreach ($PreviousState in @($PreviousManifest.ResourceTypeStates)) { if ($PreviousState.ResourceType) { $PreviousResourceTypeStates[$PreviousState.ResourceType.ToLowerInvariant()] = $PreviousState } } $PreviousCompleteSnapshotId = if ($PreviousManifest.IsComplete -eq $true) { $PreviousManifest.SnapshotId } else { $null } $PublishedResourceTypeCounts = [ordered]@{} foreach ($ResourceType in $RequestedResourceTypes) { $PublishedResourceFolder = Join-Path -Path $ExportFolder -ChildPath $ResourceType $PublishedResourceTypeCounts[$ResourceType] = if (Test-Path -Path $PublishedResourceFolder -PathType Container) { @(Get-ChildItem -Path $PublishedResourceFolder -Filter '*.json' -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 } } $AmbiguousResourceTypeStates = @( foreach ($ResourceType in $RequestedResourceTypes) { $PreviousState = $PreviousResourceTypeStates[$ResourceType] $PublishedSnapshotId = if ($PreviousState.PublishedSnapshotId) { $PreviousState.PublishedSnapshotId } else { $PreviousCompleteSnapshotId } [PSCustomObject][ordered]@{ ResourceType = $ResourceType Status = 'PreservedStale' CapturedResourceCount = 0 AttemptResourceCount = 0 PublishedResourceCount = $PublishedResourceTypeCounts[$ResourceType] PublishedSnapshotId = $PublishedSnapshotId PublishedSource = if ($PublishedSnapshotId) { 'Snapshot' } else { 'UnknownLegacySnapshot' } AttemptSnapshotId = $Snapshot.SnapshotId } } ) $AmbiguousPartialManifest = [ordered]@{ SnapshotId = $Snapshot.SnapshotId SnapshotDisplayName = $Snapshot.DisplayName SnapshotJobStatus = $SnapshotJobStatus CapturedDateTime = $Snapshot.CreatedDateTime ResourcesToInclude = @($Snapshot.ResourcesToInclude) CapturedResourceCount = $Snapshot.ResourceCount AttemptResourceTypeCounts = [ordered]@{} PublishedResourceTypeCounts = $PublishedResourceTypeCounts ResourceTypeFileCounts = $PublishedResourceTypeCounts PublishedResourceTypes = @() StaleResourceTypes = @($RequestedResourceTypes) ResourceTypeStates = @($AmbiguousResourceTypeStates) Diagnostics = @($SnapshotDiagnostics) ErrorDetailsUnavailable = $SnapshotDiagnostics.Count -eq 0 IsComplete = $false } $AmbiguousPartialManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $LastAttemptManifestFilePath -Encoding utf8 $AmbiguousPartialManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $SnapshotManifestFilePath -Encoding utf8 if (Test-Path -Path $PendingJobFilePath -ErrorAction SilentlyContinue) { Remove-Item -Path $PendingJobFilePath -Force } Write-Warning "Tenant Governance snapshot job $($Snapshot.SnapshotId) completed as '$SnapshotJobStatus', but Graph returned no parseable resource-type errors. No downloaded resource types were published; all existing resource files were preserved as stale." return [PSCustomObject]@{ TenantId = $Global:TenantIdContext TenantName = $Global:TenantNameContext SnapshotId = $Snapshot.SnapshotId SnapshotDisplayName = $Snapshot.DisplayName SnapshotJobStatus = $SnapshotJobStatus CapturedDateTime = $Snapshot.CreatedDateTime ResourcesToInclude = @($Snapshot.ResourcesToInclude) ResourceCount = $Snapshot.ResourceCount } } $PublishableResourceTypes = @($RequestedResourceTypes | Where-Object { $StaleResourceTypes -notcontains $_ }) $StagingFolder = Join-Path -Path $ExportFolder -ChildPath ".staging-$($Snapshot.SnapshotId)" $BackupFolder = Join-Path -Path $ExportFolder -ChildPath ".backup-$($Snapshot.SnapshotId)" # Recover an interrupted earlier promotion of this same job before rebuilding its staging area. if (Test-Path -Path $BackupFolder -ErrorAction SilentlyContinue) { foreach ($BackupResourceFolder in @(Get-ChildItem -Path $BackupFolder -Directory -ErrorAction SilentlyContinue)) { $PublishedResourceFolder = Join-Path -Path $ExportFolder -ChildPath $BackupResourceFolder.Name if (-not (Test-Path -Path $PublishedResourceFolder -ErrorAction SilentlyContinue)) { Move-Item -Path $BackupResourceFolder.FullName -Destination $PublishedResourceFolder -Force } } Remove-Item -Path $BackupFolder -Recurse -Force } if (Test-Path -Path $StagingFolder -ErrorAction SilentlyContinue) { Remove-Item -Path $StagingFolder -Recurse -Force } #region Stage each captured resource as an individual file under <resourceType>/<displayName segment 1>/<displayName segment 2>.json # File names are resolved for all resources first: normalization and case-insensitive filesystems # both map several display names onto the same file name, and which of the colliding resources # keeps the plain name must not depend on the order in which Graph returned them. # Collections whose Graph order is unstable but semantically irrelevant, keyed by lowercase # resource type. Keep this registry resource-specific: identically named collections in another # resource type can be positional (for example, role-setting approvalStages) and must retain # Graph's order. $CollectionPathsToSortByResourceType = @{ 'microsoft.entra.administrativeunit' = @('properties.Members', 'properties.ScopedRoleMembers') 'microsoft.entra.group' = @('properties.Members') 'microsoft.entra.authorizationpolicy' = @('properties.PermissionGrantPolicyIdsAssignedToDefaultUserRole') } $StagedResources = [System.Collections.Generic.List[object]]::new() $CapturedResourceIdentities = @{} Write-Output "Saving $($Snapshot.ResourceCount) resource(s) under $ExportFolder (organized by resource type and display name)..." foreach ($Resource in @($Snapshot.Resources)) { $ResourceType = if ($Resource.resourceType) { $Resource.resourceType } else { "unknown" } $DisplayName = if ($Resource.displayName) { $Resource.displayName } else { "Unnamed" } # UTCM sometimes appends nested hashtable .ToString() values to partner display names. # Partners have no independent display name; their tenant ID is the stable identity. if ($ResourceType -eq 'microsoft.entra.crossTenantAccessPolicyConfigurationPartner') { $PartnerTenantId = $Resource.properties.partnerTenantId if ([string]::IsNullOrWhiteSpace([string]$PartnerTenantId) -and $DisplayName -match '(?i)\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b') { $PartnerTenantId = $Matches[0] } if (-not [string]::IsNullOrWhiteSpace([string]$PartnerTenantId)) { $DisplayName = "AADCrossTenantAccessPolicyConfigurationPartner-$PartnerTenantId" if ($Resource -is [System.Collections.IDictionary]) { $Resource['displayName'] = $DisplayName } else { $Resource.displayName = $DisplayName } } } # Split the displayName on its first "-" only: everything before becomes the subfolder, # everything after (which may itself contain further "-" characters) becomes the file name. $DelimiterIndex = $DisplayName.IndexOf('-') if ($DelimiterIndex -ge 0) { $CategorySegment = $DisplayName.Substring(0, $DelimiterIndex).Trim() $NameSegment = $DisplayName.Substring($DelimiterIndex + 1).Trim() } else { $CategorySegment = "General" $NameSegment = $DisplayName.Trim() } if ([string]::IsNullOrWhiteSpace($CategorySegment)) { $CategorySegment = "General" } if ([string]::IsNullOrWhiteSpace($NameSegment)) { $NameSegment = $DisplayName.Trim() } # Determine the leaf file name based on -SnapshotResourceFileNaming. "ResourceId" falls back # to the DisplayName-based name segment if the resource doesn't expose a usable Id (e.g. # singleton resources keyed by IsSingleInstance rather than Id). $FileNameSegment = $NameSegment if ($SnapshotResourceFileNaming -eq 'ResourceId') { $ResourceId = $Resource.properties.Id if (-not [string]::IsNullOrWhiteSpace($ResourceId)) { $FileNameSegment = $ResourceId } else { Write-Verbose "Resource '$DisplayName' has no usable Id; falling back to DisplayName-based file naming." } } # Normalize each path segment so the resulting file can be created, committed, and checked # out on every platform (see ConvertTo-EntraOpsSafeFilePathSegment) and so oversized display # names cannot exceed the filesystem's per-component length limit. # Resource type identifiers are case-insensitive. Canonicalize their directory name because # publication and manifest processing use lowercase identifiers; otherwise a mixed-case # resource type stages into a different directory on case-sensitive filesystems. $SafeResourceType = ConvertTo-EntraOpsSafeFilePathSegment -Segment $ResourceType.ToLowerInvariant() -FallbackName 'unknown' $SafeCategorySegment = ConvertTo-EntraOpsSafeFilePathSegment -Segment $CategorySegment -FallbackName 'General' $SafeFileNameSegment = ConvertTo-EntraOpsSafeFilePathSegment -Segment $FileNameSegment -FallbackName 'Unnamed' $ResourceFolder = Join-Path -Path (Join-Path -Path $StagingFolder -ChildPath $SafeResourceType) -ChildPath $SafeCategorySegment $ResourceFilePath = Join-Path -Path $ResourceFolder -ChildPath "$SafeFileNameSegment.json" # Property names are sorted at every nesting level. Collection order is retained unless the # collection is registered above as a known semantic set. $CanonicalResourceType = $ResourceType.ToLowerInvariant() [string[]]$CollectionPathsToSort = @() if ($CollectionPathsToSortByResourceType.ContainsKey($CanonicalResourceType)) { $CollectionPathsToSort = $CollectionPathsToSortByResourceType[$CanonicalResourceType] } $SortedResource = ConvertTo-EntraOpsSortedObject -InputObject $Resource -CollectionPathsToSort $CollectionPathsToSort $ResourceJson = $SortedResource | ConvertTo-Json -Depth 10 # The Id stays stable while the resource is edited or renamed, so it is preferred over the # content as the discriminator seed for resources that need one. $ResourceIdentifier = [string]$Resource.properties.Id if (-not [string]::IsNullOrWhiteSpace($ResourceIdentifier)) { $CanonicalIdentity = "$($ResourceType.ToLowerInvariant())|$($ResourceIdentifier.Trim().ToLowerInvariant())" if ($CapturedResourceIdentities.ContainsKey($CanonicalIdentity)) { # This condition is deterministic for the job, so retrying the same job can never # succeed. Release the pending-job state and record the failure so the next Start # can create a new job and the report/analyzer can show why this one was rejected. $DuplicateMessage = "Snapshot job $($Snapshot.SnapshotId) returned duplicate canonical resource identity '$CanonicalIdentity' for '$DisplayName' and '$($CapturedResourceIdentities[$CanonicalIdentity])'. No snapshot resource folders were published." $DuplicateAttemptManifest = [ordered]@{ SnapshotId = $Snapshot.SnapshotId SnapshotDisplayName = $Snapshot.DisplayName SnapshotJobStatus = $SnapshotJobStatus CapturedDateTime = $Snapshot.CreatedDateTime ResourcesToInclude = @($Snapshot.ResourcesToInclude) CapturedResourceCount = $Snapshot.ResourceCount AttemptResourceTypeCounts = [ordered]@{} PublishedResourceTypeCounts = [ordered]@{} ResourceTypeFileCounts = [ordered]@{} PublishedResourceTypes = @() StaleResourceTypes = @($RequestedResourceTypes) ResourceTypeStates = @() Diagnostics = @( [PSCustomObject][ordered]@{ ResourceType = $ResourceType.ToLowerInvariant() ErrorCategory = 'DuplicateResourceIdentity' ErrorCode = 'DuplicateCanonicalIdentity' Occurrences = 1 Message = $DuplicateMessage RemediationHint = 'Microsoft Graph returned two resources with the same id for this resource type. Inspect the tenant objects, then start a new snapshot job; the rejected job is not retried.' } ) ErrorDetailsUnavailable = $false IsComplete = $false } $DuplicateAttemptManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $LastAttemptManifestFilePath -Encoding utf8 if (Test-Path -Path $StagingFolder -ErrorAction SilentlyContinue) { Remove-Item -Path $StagingFolder -Recurse -Force } if (Test-Path -Path $PendingJobFilePath -ErrorAction SilentlyContinue) { Remove-Item -Path $PendingJobFilePath -Force } throw $DuplicateMessage } $CapturedResourceIdentities[$CanonicalIdentity] = $DisplayName } $StagedResources.Add([PSCustomObject]@{ ResourceType = $ResourceType DisplayName = $DisplayName SafeFileNameSegment = $SafeFileNameSegment ResourceFolder = $ResourceFolder ResourceFilePath = $ResourceFilePath ResourceFilePathKey = $ResourceFilePath.ToLowerInvariant() DiscriminatorSeed = if ([string]::IsNullOrWhiteSpace($ResourceIdentifier)) { $ResourceJson } else { $ResourceIdentifier } Json = $ResourceJson }) } $ResourceFilePathCounts = @{} foreach ($StagedResource in $StagedResources) { $ResourceFilePathCounts[$StagedResource.ResourceFilePathKey] = 1 + [int]$ResourceFilePathCounts[$StagedResource.ResourceFilePathKey] } # Allocate a unique path for every resource before writing anything: a discriminated name can # itself collide with another resource's natural name, so every candidate is checked against all # paths allocated so far. Allocation runs in a deterministic order to keep the outcome # independent of the order in which Graph returned the resources. $AllocatedFilePathKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($StagedResource in @($StagedResources | Sort-Object -Property ResourceFilePathKey, DiscriminatorSeed)) { $SafeFileNameSegment = $StagedResource.SafeFileNameSegment $ResourceFilePath = $StagedResource.ResourceFilePath $NeedsDiscriminator = $ResourceFilePathCounts[$StagedResource.ResourceFilePathKey] -gt 1 $Attempt = 0 while ($true) { if ($NeedsDiscriminator) { $DiscriminatorBytes = [System.Security.Cryptography.MD5]::HashData([System.Text.Encoding]::UTF8.GetBytes("$($StagedResource.DiscriminatorSeed)|$Attempt")) $Discriminator = ([System.BitConverter]::ToString($DiscriminatorBytes) -replace '-', '').Substring(0, 8) $SafeFileNameSegment = ConvertTo-EntraOpsSafeFilePathSegment -Segment "$($StagedResource.SafeFileNameSegment) $Discriminator" -FallbackName $Discriminator $ResourceFilePath = Join-Path -Path $StagedResource.ResourceFolder -ChildPath "$SafeFileNameSegment.json" } if ($AllocatedFilePathKeys.Add($ResourceFilePath.ToLowerInvariant())) { break } $NeedsDiscriminator = $true $Attempt++ if ($Attempt -gt 100) { throw "Failed to allocate a unique file name for resource '$($StagedResource.DisplayName)' ($($StagedResource.ResourceType)) after $Attempt attempts. Re-run with -SnapshotResourceFileNaming ResourceId." } } if ($SafeFileNameSegment -ne $StagedResource.SafeFileNameSegment) { Write-Warning "Resource '$($StagedResource.DisplayName)' ($($StagedResource.ResourceType)) resolves to the same file name as another resource of this snapshot. It was saved as '$SafeFileNameSegment.json' instead. Use -SnapshotResourceFileNaming ResourceId for unique and rename-stable file names." } if (-not (Test-Path -LiteralPath $StagedResource.ResourceFolder)) { New-Item -Path $StagedResource.ResourceFolder -ItemType Directory -Force | Out-Null } $StagedResource.Json | Out-File -LiteralPath $ResourceFilePath -Encoding utf8 } #endregion #region Build per-resource-type publication metadata # Snapshot resources arrive in an arbitrary order. Rebuild the count dictionary with ordinally # sorted keys so ConvertTo-Json emits byte-stable manifest metadata. $AttemptResourceTypeCounts = [ordered]@{} [string[]]$AttemptResourceTypeKeys = @($UnorderedAttemptResourceTypeCounts.Keys) [Array]::Sort($AttemptResourceTypeKeys, [StringComparer]::Ordinal) foreach ($ResourceType in $AttemptResourceTypeKeys) { $AttemptResourceTypeCounts[$ResourceType] = $UnorderedAttemptResourceTypeCounts[$ResourceType] } $PreviousManifest = $null if (Test-Path -Path $SnapshotManifestFilePath -ErrorAction SilentlyContinue) { try { $PreviousManifest = Get-Content -Path $SnapshotManifestFilePath -Raw | ConvertFrom-Json -Depth 10 } catch { Write-Warning "The previous snapshot manifest could not be read. Stale resource files will still be preserved, but their source snapshot cannot be identified. Error: $_" } } $PreviousResourceTypeStates = @{} foreach ($PreviousState in @($PreviousManifest.ResourceTypeStates)) { if ($PreviousState.ResourceType) { $PreviousResourceTypeStates[$PreviousState.ResourceType.ToLowerInvariant()] = $PreviousState } } #endregion #region Promote the staged resource types and roll back all promoted directories if publication fails $PromotionRecords = [System.Collections.Generic.List[object]]::new() $RetainedResourceCounts = @{} try { New-Item -Path $BackupFolder -ItemType Directory -Force | Out-Null foreach ($ResourceType in $PublishableResourceTypes) { $SafeResourceType = ConvertTo-EntraOpsSafeFilePathSegment -Segment $ResourceType -FallbackName 'unknown' $StagedResourceFolder = Join-Path -Path $StagingFolder -ChildPath $SafeResourceType $PublishedResourceFolder = Join-Path -Path $ExportFolder -ChildPath $SafeResourceType $ResourceBackupFolder = Join-Path -Path $BackupFolder -ChildPath $SafeResourceType if (Test-Path -Path $PublishedResourceFolder -ErrorAction SilentlyContinue) { Move-Item -Path $PublishedResourceFolder -Destination $ResourceBackupFolder -Force } $PromotionRecords.Add([PSCustomObject]@{ PublishedFolder = $PublishedResourceFolder BackupFolder = $ResourceBackupFolder }) if (Test-Path -Path $StagedResourceFolder -ErrorAction SilentlyContinue) { Move-Item -Path $StagedResourceFolder -Destination $PublishedResourceFolder -Force } if ($ResourceTypesWithRetainedResources -notcontains $ResourceType) { continue } $RetainedResourceCounts[$ResourceType] = 0 if (-not (Test-Path -Path $ResourceBackupFolder -PathType Container -ErrorAction SilentlyContinue)) { continue } # Match retained files by resource identity, not file name: a resource that was renamed # in this attempt would otherwise be kept twice and fail the duplicate-identity check. $RetainedResourceIdentities = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $PreviousResourceFiles = @( foreach ($PreviousFile in @(Get-ChildItem -LiteralPath $ResourceBackupFolder -Filter '*.json' -File -Recurse)) { $PreviousResourceId = $null # -AsHashtable: Graph emits empty property names in some payloads, which ConvertFrom-Json # otherwise rejects, leaving the resource without an identity and retaining it twice. try { $PreviousResourceId = [string](Get-Content -LiteralPath $PreviousFile.FullName -Raw | ConvertFrom-Json -AsHashtable -Depth 100).properties.Id } catch { $PreviousResourceId = $null } $HasPreviousResourceId = -not [string]::IsNullOrWhiteSpace($PreviousResourceId) $ExpectedIdFileName = if ($HasPreviousResourceId) { ConvertTo-EntraOpsSafeFilePathSegment -Segment $PreviousResourceId -FallbackName 'Unnamed' } else { $null } [PSCustomObject]@{ File = $PreviousFile ResourceId = $PreviousResourceId HasResourceId = $HasPreviousResourceId IsIdNamed = $HasPreviousResourceId -and [System.IO.Path]::GetFileNameWithoutExtension($PreviousFile.Name).Equals($ExpectedIdFileName, [System.StringComparison]::OrdinalIgnoreCase) } } ) foreach ($PreviousResourceFile in @($PreviousResourceFiles | Sort-Object @{ Expression = { if ($_.IsIdNamed) { 0 } else { 1 } } }, @{ Expression = { $_.File.FullName } })) { $PreviousFile = $PreviousResourceFile.File $PreviousResourceId = $PreviousResourceFile.ResourceId $HasPreviousResourceId = $PreviousResourceFile.HasResourceId if ($HasPreviousResourceId) { $PreviousIdentity = "$ResourceType|$($PreviousResourceId.Trim().ToLowerInvariant())" if ($CapturedResourceIdentities.ContainsKey($PreviousIdentity) -or -not $RetainedResourceIdentities.Add($PreviousIdentity)) { continue } } $RelativePath = $PreviousFile.FullName.Substring($ResourceBackupFolder.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) $RetainedFilePath = Join-Path -Path $PublishedResourceFolder -ChildPath $RelativePath if (Test-Path -LiteralPath $RetainedFilePath) { # Without an identity the same path is the only evidence of the same resource. if (-not $HasPreviousResourceId) { continue } # A different resource now owns this display-name path; keep the failed one # under a discriminated name derived from its stable identity. $DiscriminatorBytes = [System.Security.Cryptography.MD5]::HashData([System.Text.Encoding]::UTF8.GetBytes("$PreviousResourceId|retained")) $Discriminator = ([System.BitConverter]::ToString($DiscriminatorBytes) -replace '-', '').Substring(0, 8) $RetainedFileName = ConvertTo-EntraOpsSafeFilePathSegment -Segment "$([System.IO.Path]::GetFileNameWithoutExtension($RetainedFilePath)) $Discriminator" -FallbackName $Discriminator $RetainedFilePath = Join-Path -Path (Split-Path -Path $RetainedFilePath -Parent) -ChildPath "$RetainedFileName.json" if (Test-Path -LiteralPath $RetainedFilePath) { continue } Write-Warning "Retained resource '$PreviousResourceId' ($ResourceType) no longer owns its file name; it was kept as '$RetainedFileName.json'." } $RetainedFolder = Split-Path -Path $RetainedFilePath -Parent if (-not (Test-Path -LiteralPath $RetainedFolder)) { New-Item -Path $RetainedFolder -ItemType Directory -Force | Out-Null } Copy-Item -LiteralPath $PreviousFile.FullName -Destination $RetainedFilePath -Force $RetainedResourceCounts[$ResourceType]++ } if ($RetainedResourceCounts[$ResourceType] -gt 0) { Write-Warning "Resource type '$ResourceType' was published with export errors. $($RetainedResourceCounts[$ResourceType]) resource file(s) the snapshot job did not return were retained from the previously published snapshot." } } # Validation scans every published resource folder, including preserved-stale and no-longer # configured types. Heal only the unambiguous naming migration case: one exact ID-named file # plus one or more legacy display-name aliases for the same canonical resource identity. $PublishedIdentityFiles = @{} foreach ($PublishedFile in @(Get-ChildItem -LiteralPath $ExportFolder -Filter '*.json' -File -Recurse | Where-Object { $_.FullName -notmatch '[\\/]\.(?:staging|backup)-' })) { $PublishedResource = $null try { $PublishedResource = Get-Content -LiteralPath $PublishedFile.FullName -Raw | ConvertFrom-Json -AsHashtable -Depth 100 } catch { continue } $PublishedResourceType = ([string]$PublishedResource.resourceType).Trim().ToLowerInvariant() $PublishedResourceId = ([string]$PublishedResource.properties.Id).Trim().ToLowerInvariant() if ([string]::IsNullOrWhiteSpace($PublishedResourceType) -or [string]::IsNullOrWhiteSpace($PublishedResourceId)) { continue } $PublishedIdentity = "$PublishedResourceType|$PublishedResourceId" $ExpectedIdFileName = ConvertTo-EntraOpsSafeFilePathSegment -Segment $PublishedResourceId -FallbackName 'Unnamed' if (-not $PublishedIdentityFiles.ContainsKey($PublishedIdentity)) { $PublishedIdentityFiles[$PublishedIdentity] = [System.Collections.Generic.List[object]]::new() } $PublishedIdentityFiles[$PublishedIdentity].Add([PSCustomObject]@{ File = $PublishedFile IsIdNamed = [System.IO.Path]::GetFileNameWithoutExtension($PublishedFile.Name).Equals($ExpectedIdFileName, [System.StringComparison]::OrdinalIgnoreCase) }) } foreach ($PublishedIdentity in @($PublishedIdentityFiles.Keys | Sort-Object)) { $IdentityFiles = @($PublishedIdentityFiles[$PublishedIdentity]) if ($IdentityFiles.Count -lt 2) { continue } $IdNamedFiles = @($IdentityFiles | Where-Object { $_.IsIdNamed }) if ($IdNamedFiles.Count -ne 1) { continue } foreach ($LegacyFile in @($IdentityFiles | Where-Object { -not $_.IsIdNamed })) { Remove-Item -LiteralPath $LegacyFile.File.FullName -Force Write-Warning "Removed legacy Tenant Governance alias '$($LegacyFile.File.Name)' for '$PublishedIdentity'; the ID-named copy was retained." } } } catch { for ($Index = $PromotionRecords.Count - 1; $Index -ge 0; $Index--) { $PromotionRecord = $PromotionRecords[$Index] if (Test-Path -Path $PromotionRecord.PublishedFolder -ErrorAction SilentlyContinue) { Remove-Item -Path $PromotionRecord.PublishedFolder -Recurse -Force } if (Test-Path -Path $PromotionRecord.BackupFolder -ErrorAction SilentlyContinue) { Move-Item -Path $PromotionRecord.BackupFolder -Destination $PromotionRecord.PublishedFolder -Force } } foreach ($TemporaryFolder in @($StagingFolder, $BackupFolder)) { if (Test-Path -Path $TemporaryFolder -ErrorAction SilentlyContinue) { Remove-Item -Path $TemporaryFolder -Recurse -Force } } throw "Failed to publish Tenant Governance snapshot resources. Existing resource folders were restored and pending job state was preserved. Error: $_" } #endregion #region Build published counts and per-resource-type states after successful promotion $PublishedResourceTypeCounts = [ordered]@{} foreach ($ResourceType in $RequestedResourceTypes) { $PublishedResourceFolder = Join-Path -Path $ExportFolder -ChildPath $ResourceType $PublishedResourceTypeCounts[$ResourceType] = if (Test-Path -Path $PublishedResourceFolder -PathType Container) { @(Get-ChildItem -Path $PublishedResourceFolder -Filter '*.json' -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 } } $ResourceTypeStates = @( foreach ($ResourceType in $RequestedResourceTypes) { $IsStale = $StaleResourceTypes -contains $ResourceType $IsRetained = $ResourceTypesWithRetainedResources -contains $ResourceType $PreviousState = $PreviousResourceTypeStates[$ResourceType] $PreviousCompleteSnapshotId = if ($PreviousManifest.IsComplete -eq $true) { $PreviousManifest.SnapshotId } else { $null } $PublishedSnapshotId = if ($IsStale) { if ($PreviousState.PublishedSnapshotId) { $PreviousState.PublishedSnapshotId } else { $PreviousCompleteSnapshotId } } else { $Snapshot.SnapshotId } [PSCustomObject][ordered]@{ ResourceType = $ResourceType Status = if ($IsStale) { 'PreservedStale' } elseif ($IsRetained) { 'PublishedWithErrors' } else { 'Published' } CapturedResourceCount = if ($AttemptResourceTypeCounts.Contains($ResourceType)) { $AttemptResourceTypeCounts[$ResourceType] } else { 0 } AttemptResourceCount = if ($AttemptResourceTypeCounts.Contains($ResourceType)) { $AttemptResourceTypeCounts[$ResourceType] } else { 0 } PublishedResourceCount = $PublishedResourceTypeCounts[$ResourceType] RetainedResourceCount = if ($RetainedResourceCounts.ContainsKey($ResourceType)) { [int]$RetainedResourceCounts[$ResourceType] } else { 0 } PublishedSnapshotId = $PublishedSnapshotId PublishedSource = if ($PublishedSnapshotId) { 'Snapshot' } else { 'UnknownLegacySnapshot' } AttemptSnapshotId = $Snapshot.SnapshotId Diagnostics = @($SnapshotDiagnostics | Where-Object { $_.ResourceType -eq $ResourceType }) } } ) #endregion #region Build the command summary and persisted snapshot manifests $SnapshotSummary = [PSCustomObject]@{ TenantId = $Global:TenantIdContext TenantName = $Global:TenantNameContext SnapshotId = $Snapshot.SnapshotId SnapshotDisplayName = $Snapshot.DisplayName SnapshotJobStatus = $SnapshotJobStatus CapturedDateTime = $Snapshot.CreatedDateTime ResourcesToInclude = $Snapshot.ResourcesToInclude ResourceCount = $Snapshot.ResourceCount } $SnapshotManifest = [ordered]@{ SnapshotId = $Snapshot.SnapshotId SnapshotDisplayName = $Snapshot.DisplayName SnapshotJobStatus = $SnapshotJobStatus CapturedDateTime = $Snapshot.CreatedDateTime ResourcesToInclude = @($Snapshot.ResourcesToInclude) CapturedResourceCount = $Snapshot.ResourceCount AttemptResourceTypeCounts = $AttemptResourceTypeCounts PublishedResourceTypeCounts = $PublishedResourceTypeCounts ResourceTypeFileCounts = $PublishedResourceTypeCounts PublishedResourceTypes = @($PublishableResourceTypes) PublishedWithErrorsResourceTypes = @($ResourceTypesWithRetainedResources) StaleResourceTypes = @($StaleResourceTypes) ResourceTypeStates = @($ResourceTypeStates) Diagnostics = @($SnapshotDiagnostics) ErrorDetailsUnavailable = $ParsedErrorDetails.Count -eq 0 -and $SnapshotJobStatus -eq 'partiallySuccessful' IsComplete = $SnapshotJobStatus -in @('completed', 'succeeded') -and $ResourceTypesWithErrors.Count -eq 0 } $SnapshotManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $LastAttemptManifestFilePath -Encoding utf8 $SnapshotManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $SnapshotManifestFilePath -Encoding utf8 foreach ($TemporaryFolder in @($StagingFolder, $BackupFolder)) { if (Test-Path -Path $TemporaryFolder -ErrorAction SilentlyContinue) { Remove-Item -Path $TemporaryFolder -Recurse -Force } } if (Test-Path -Path $PendingJobFilePath -ErrorAction SilentlyContinue) { Remove-Item -Path $PendingJobFilePath -Force } if ($SnapshotManifest.IsComplete) { Write-Output "Tenant Governance snapshot completed. $($Snapshot.ResourceCount) resource(s) captured and saved under $ExportFolder. Commit and push these files to track this point-in-time in Git history." } else { Write-Warning "Tenant Governance snapshot job $($Snapshot.SnapshotId) completed as '$SnapshotJobStatus'. $($PublishableResourceTypes.Count) resource type(s) were published ($($ResourceTypesWithRetainedResources.Count) of them with per-resource export errors) and $($StaleResourceTypes.Count) type(s) with Graph errors were preserved as stale." } Write-Output ($SnapshotSummary | Format-List | Out-String) #endregion return $SnapshotSummary } # SIG # Begin signature block # MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDNcWotJU1MhLCk # AzN+hf+yVmrOLRwlgvFU7ZZLJ+oee6CCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j # 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 # ARUwLwYJKoZIhvcNAQkEMSIEIMMS5DjjGGuw8faV3NnUezRS/pUwA/KzsLeOe5Rl # wLw7MA0GCSqGSIb3DQEBAQUABIIBgG0Tuhrg7Cqxvgz/+CfA6fWdFwZr+C/ni+YM # DzMThQYquD1Z+MDIUKpqdDyHa3GA/CoGS3Y+kDMSNIz0ujLBlHiKRfbAnZEyib3k # olwoEtmUCdhLak3qZi+w7bIIySlXw/5QgB1ozfy0TiNK14Ieq9QtmG7bWQz85BnZ # YRvFGqI6vE/qyzt4/pZXLqubZYsboB/N8fxdI3H2x8Iw1qANU59f53M6/IgQ8FhI # F+aBAs8pj1x6RwPSLyf2h76RfpJjcAbweI8yiqrpcBIA901bxY9dXmmO/QvXslsa # A7nDxVboPfZ01nYniZ04fZkw2C617NMoXyeiBrrSYie1SX2v6unJyH+a8fULqePx # du8iXIgo0b/2XUwl8ONFpjunxAoj8hqIPmgwVxkXVQWKXVP5iO2h/CRR5i4x2CsB # dg/xVovUqE1rF3TuORaX8n67G8E0Ks2AGTEyEVzxGFx0/Ad2CquuSm0oUq8N+by7 # HdLkqrAuqT/gcnVoT8GT5VEktjml3aGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID # 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0 # ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ # KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD # MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQyMTJaMDcG # CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY # 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDD+yFL2uIyI/T4hUCkvKLbexAMUS0+bpv1U # LVdxMPSfdDjhorYF6uG9JGTOdWKUxrAwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ # MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh # MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0 # dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI # hvcNAQEBBQAEggIAcAoUWABW4VLcw48/aypjX9Wo7uewPOlP72cxzWkPzZRIbW/4 # MRa+jGaSzUYvueRdMOPvkpRFeD/pbGnli5+ofka0miybFdsIZM2Krsa6sQ2aTmGq # lj0uktD+0wr+czemr6rxxuNlhaWJElOY6NpLMARIk3G9rfnLXmjYspCtLLJt9Ix5 # a6Q4rUxbv+sVpdwjv/HzVx158c/9lcr8fxxgCQKiRQOgDYtVjm0ezFOLhVkM5MPy # TyNToTRQLN5hEcXljfzURen3fKjZjVZdLow386fMIR6E0HI9WbPw9mgVMAHOyM6G # IerQT7dXJ4HLrX76YS07M/A8pOmwUYGUVZNj6K6nxZLIbYW+PI1lAwKO5AFVjCQF # dkqiU0jGLUyMkNnRK/fwdVZotm+aLgxyv9ben/6zhnwvLSLWyjY8A2j/jSEHw8Vd # wN0H5l+28vKmlSiH4evIEY8EME+kEePjxNpVkNBv7q63ytU7tMPNhu9KpRCmAApa # OwhNtKhkyTuFXeMtXD6p+kBqEnU5aHD0+UnH4ZmWMqVtDEcopK4dhvwwTHQsx/Av # Nm2eRzYIkLHyJQDwnEi0oCtD5hWP/c+siWye1muzSoADE7ACmzK7syM0T2RWLzAL # 86aLx626tDfJvPB33Naa1quFWXcC5ZeYyTVUfIaNmvoPJE5YkXovpj8vCsI= # SIG # End signature block |