Public/Reportings/New-EntraOpsTenantGovernanceConfigurationAnalyzerData.ps1

<#
.SYNOPSIS
    Generate (refresh) the dataset for the EntraOps Configuration Analyzer static web app.

.DESCRIPTION
    Walks the git history of the Tenant Governance snapshot folder
    (TenantGovernance/Snapshots/<resourceType>/<category>/<name>.json, written by
    Save-EntraOpsTenantGovernanceSnapshotJson and committed by the
    Pull-EntraOpsTenantGovernance workflow) and builds one snapshot per commit that
    changed it, so the Configuration Analyzer app can show configuration drift over
    time: which resources were added, modified or removed between any two snapshots,
    including property-level diffs, and a Conditional Access Sankey visualization of
    the policy set at any point in time.

    For every commit, only the git tree listing (file path + blob hash) is recorded -
    that is enough for the app to derive added/modified/removed resources between any
    two snapshots. The actual resource JSON content is embedded only for up to
    -MaxDetailedSnapshots snapshots (evenly spread across the full range, always
    including the oldest and newest), and content blobs are de-duplicated by git blob
    hash across all snapshots, so unchanged resources are stored exactly once no
    matter how many snapshots reference them.

    When the working tree contains snapshot files that differ from the newest commit
    (or the folder has never been committed at all), an additional "(working tree)"
    snapshot with the current on-disk state is appended, so the app is usable before
    the first commit and always reflects the latest captured state.

    Generates data/configuration-analyzer-data.js exposing
    `window.ENTRAOPS_CONFIGANALYZER_DATA` so the static web app works both when served
    over HTTP and when opened directly from the file system. The Configuration
    Analyzer app shows setup instructions instead of failing until this file exists.

    Requires the repository to be a git working copy (git history is the only source
    of historic data - there is no separate time-series store).

.PARAMETER RepoRoot
    Path to the EntraOps repository root (and git working copy) that contains the
    TenantGovernance snapshot folder. Defaults to the repository this module lives in.

.PARAMETER ImportPath
    Folder with the Tenant Governance snapshot export whose git history is walked.
    Defaults to <RepoRoot>/TenantGovernance/Snapshots. Must be inside the git working
    copy of RepoRoot.

.PARAMETER AppRoot
    Path to the Configuration Analyzer app folder (where the generated content is
    written). Defaults to Reports/ConfigurationAnalyzer in the EntraOps repository.

.PARAMETER OutFile
    Output file. Defaults to <AppRoot>/data/configuration-analyzer-data.js.

.PARAMETER TimeRangeInDays
    Only consider commits from the last N days. Default ($null) considers the full git
    history of the snapshot folder (every commit that changed it).

.PARAMETER SnapshotInterval
    Minimum spacing between two consecutive snapshots, as an ISO 8601 duration: `P<n>D`
    (days), `P<n>W` (weeks), `P<n>M` (months) or `P<n>Y` (years). Commits closer
    together than the interval are skipped; the oldest and the most recent commit in
    range are always kept. Default `None`: keep every commit, since configuration
    changes (unlike privilege trends) are individually interesting.

.PARAMETER MaxDetailedSnapshots
    Only up to this many snapshots (evenly spread across the full range, always
    including the oldest and newest) embed the full resource JSON content (used for
    property-level diffs and the Conditional Access Sankey); the rest keep their file
    listing (enough for added/modified/removed counts) but no content. Default 30.
    Use 0 to embed content for every snapshot (only recommended for short histories).

.PARAMETER ResolveGroupMembersForPrivilegedAssets
    Resolve groups targeted by Conditional Access IncludeGroups and authentication method policy
    IncludeTargets through their PIM-aware transitive membership. The most privileged member found
    in the local PrivilegedEAM export determines the group's access tier. Defaults to
    ConfigurationAnalyzer.ResolveGroupMembersForPrivilegedAssets in EntraOpsConfig.json, or true
    when no setting is present. Requires an active Microsoft Graph connection.

.PARAMETER MaxSnapshotAgeHours
Maximum age of the successful Tenant Governance snapshot manifest. Default 30 hours.
Use -AllowStaleSnapshot to generate a report from an older known-good snapshot.

.PARAMETER AllowStaleSnapshot
Allow report generation when the snapshot manifest is older than -MaxSnapshotAgeHours.
Snapshot completeness is validated separately; use -AllowPartialSnapshot as well when both
overrides are intentionally required.

.PARAMETER AllowPartialSnapshot
Allow the current mixed snapshot tree to be analyzed when the latest attempt was partially
successful. Preserved resource types remain marked stale in snapshotManifest. Historical partial
commits are still excluded because their trees can combine files from multiple capture times.

.PARAMETER SkipWorkingTree
    Do not append the "(working tree)" snapshot for uncommitted on-disk changes.

.PARAMETER PassThru
    Emit the generated payload object to the pipeline.

.EXAMPLE
    New-EntraOpsTenantGovernanceConfigurationAnalyzerData

    Regenerates the Configuration Analyzer dataset from the full git history of the
    TenantGovernance/Snapshots folder of this repository.

.EXAMPLE
    New-EntraOpsTenantGovernanceConfigurationAnalyzerData -TimeRangeInDays 90 -Verbose -WhatIf

    Shows what would be generated from the last 90 days of history without changing
    any files.

.EXAMPLE
    New-EntraOpsTenantGovernanceConfigurationAnalyzerData -AllowPartialSnapshot

    Includes the current mixed snapshot tree after a partially successful capture. Historical
    partial commits remain excluded from the timeline.
#>


function New-EntraOpsTenantGovernanceConfigurationAnalyzerData {

    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $false)]
        [System.String]$RepoRoot,

        [Parameter(Mandatory = $false)]
        [System.String]$ImportPath,

        [Parameter(Mandatory = $false)]
        [System.String]$AppRoot,

        [Parameter(Mandatory = $false)]
        [System.String]$OutFile,

        [Parameter(Mandatory = $false)]
        [System.Nullable[int]]$TimeRangeInDays,

        [Parameter(Mandatory = $false)]
        [System.String]$SnapshotInterval = 'None',

        [Parameter(Mandatory = $false)]
        [int]$MaxDetailedSnapshots = 30,

        [Parameter(Mandatory = $false)]
        [System.Nullable[bool]]$ResolveGroupMembersForPrivilegedAssets,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 8760)]
        [int]$MaxSnapshotAgeHours = 30,

        [Parameter(Mandatory = $false)]
        [switch]$AllowStaleSnapshot,

        [Parameter(Mandatory = $false)]
        [switch]$AllowPartialSnapshot,

        [Parameter(Mandatory = $false)]
        [switch]$SkipWorkingTree,

        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )

    # ---- Snapshot interval thinning (same semantics as Privilege History) ----------
    function ConvertTo-SnapshotIntervalComponents {
        param([string]$Interval)
        if ([string]::IsNullOrWhiteSpace($Interval)) { return $null }
        if ($Interval -in @('P0D', 'PT0S', 'None', 'none')) { return $null }
        if ($Interval -notmatch '^P(?:(?<y>\d+)Y)?(?:(?<mo>\d+)M)?(?:(?<w>\d+)W)?(?:(?<d>\d+)D)?$') {
            throw "Invalid -SnapshotInterval '$Interval'. Expected an ISO 8601 duration such as 'P1D' (daily), 'P1W' (weekly) or 'P1M' (monthly). Use 'P0D' or 'None' to keep every commit."
        }
        $years = if ($Matches['y']) { [int]$Matches['y'] } else { 0 }
        $months = if ($Matches['mo']) { [int]$Matches['mo'] } else { 0 }
        $weeks = if ($Matches['w']) { [int]$Matches['w'] } else { 0 }
        $daysOnly = if ($Matches['d']) { [int]$Matches['d'] } else { 0 }
        $days = ($weeks * 7) + $daysOnly
        if ($years -eq 0 -and $months -eq 0 -and $days -eq 0) { return $null }
        return [ordered]@{ Years = $years; Months = $months; Days = $days }
    }

    function Add-SnapshotInterval {
        param([datetimeoffset]$Date, [System.Collections.Specialized.OrderedDictionary]$Interval)
        $result = $Date
        if ($Interval.Years -gt 0) { $result = $result.AddYears($Interval.Years) }
        if ($Interval.Months -gt 0) { $result = $result.AddMonths($Interval.Months) }
        if ($Interval.Days -gt 0) { $result = $result.AddDays($Interval.Days) }
        return $result
    }

    $SnapshotIntervalComponents = ConvertTo-SnapshotIntervalComponents -Interval $SnapshotInterval

    # Resolve the app/repository location relative to the module location:
    # <repo>/EntraOps/Public/<subfolder> -> <repo>/Reports/ConfigurationAnalyzer
    $ModuleRoot = $MyInvocation.MyCommand.Module.ModuleBase
    if ([string]::IsNullOrWhiteSpace($ModuleRoot) -and -not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
        $ModuleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    }
    if ([string]::IsNullOrWhiteSpace($ModuleRoot)) {
        throw "Unable to resolve the EntraOps module location. Import the module with 'Import-Module <path-to-EntraOps> -Force' and try again."
    }
    $RepositoryRoot = Split-Path -Parent $ModuleRoot

    if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = $RepositoryRoot }
    if ([string]::IsNullOrWhiteSpace($AppRoot)) { $AppRoot = Join-Path $RepositoryRoot 'Reports/ConfigurationAnalyzer' }
    if (-not (Test-Path -LiteralPath $AppRoot -PathType Container)) {
        throw "Configuration Analyzer app folder not found: $AppRoot. Import the EntraOps module from a repository checkout that contains Reports/ConfigurationAnalyzer."
    }
    if ([string]::IsNullOrWhiteSpace($ImportPath)) { $ImportPath = Join-Path $RepoRoot 'TenantGovernance/Snapshots' }
    if ([string]::IsNullOrWhiteSpace($OutFile)) { $OutFile = Join-Path $AppRoot 'data/configuration-analyzer-data.js' }

    $ConfigFilePath = Join-Path $RepoRoot 'EntraOpsConfig.json'
    if (-not $PSBoundParameters.ContainsKey('ResolveGroupMembersForPrivilegedAssets')) {
        $ResolveGroupMembersForPrivilegedAssets = $true
        if (Test-Path -LiteralPath $ConfigFilePath -PathType Leaf) {
            try {
                $Config = Get-Content -LiteralPath $ConfigFilePath -Raw | ConvertFrom-Json
                if ($null -ne $Config.ConfigurationAnalyzer -and $null -ne $Config.ConfigurationAnalyzer.ResolveGroupMembersForPrivilegedAssets) {
                    $ResolveGroupMembersForPrivilegedAssets = [bool]$Config.ConfigurationAnalyzer.ResolveGroupMembersForPrivilegedAssets
                }
            } catch {
                Write-Warning "Failed to read ConfigurationAnalyzer settings from ${ConfigFilePath}: $($_.Exception.Message). Group membership resolution remains enabled."
            }
        }
    }

    $PimRequestFlowExcludedRiskFlags = @()
    $AccessPackageFlowExcludedRiskFlags = @()
    $ConditionalAccessAnalysisExcludedFindings = @()
    $EidscaExcludedFindings = @()
    if (Test-Path -LiteralPath $ConfigFilePath -PathType Leaf) {
        try {
            $Config = Get-Content -LiteralPath $ConfigFilePath -Raw | ConvertFrom-Json
            if ($null -ne $Config.ConfigurationAnalyzer -and $null -ne $Config.ConfigurationAnalyzer.PimRequestFlowExcludedRiskFlags) {
                $PimRequestFlowExcludedRiskFlags = @($Config.ConfigurationAnalyzer.PimRequestFlowExcludedRiskFlags | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) })
            }
            if ($null -ne $Config.ConfigurationAnalyzer -and $null -ne $Config.ConfigurationAnalyzer.AccessPackageFlowExcludedRiskFlags) {
                $AccessPackageFlowExcludedRiskFlags = @($Config.ConfigurationAnalyzer.AccessPackageFlowExcludedRiskFlags | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) })
            }
            if ($null -ne $Config.ConfigurationAnalyzer -and $null -ne $Config.ConfigurationAnalyzer.ConditionalAccessAnalysisExcludedFindings) {
                $ConditionalAccessAnalysisExcludedFindings = @($Config.ConfigurationAnalyzer.ConditionalAccessAnalysisExcludedFindings | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) })
            }
            if ($null -ne $Config.ConfigurationAnalyzer -and $null -ne $Config.ConfigurationAnalyzer.EidscaExcludedFindings) {
                $EidscaExcludedFindings = @($Config.ConfigurationAnalyzer.EidscaExcludedFindings | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) })
            }
        } catch {
            Write-Warning "Failed to read report finding exclusions from ${ConfigFilePath}: $($_.Exception.Message). No findings will be excluded."
        }
    }

    $ManifestPath = Join-Path $ImportPath '.SnapshotManifest.json'
    if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) {
        throw "Tenant Governance snapshot manifest not found: $ManifestPath. Run a successful Save-EntraOpsTenantGovernanceSnapshotJson capture before generating Configuration Analyzer data."
    }
    try {
        $SnapshotManifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json -Depth 10
    } catch {
        throw "Tenant Governance snapshot manifest is invalid: $ManifestPath. Error: $($_.Exception.Message)"
    }
    # A rejected later attempt (for example duplicate canonical identities) is recorded only in the
    # last-attempt manifest; its diagnostics are shown next to the published snapshot's own.
    $LastAttemptManifest = $null
    $LastAttemptManifestPath = Join-Path $ImportPath '.LastAttemptManifest.json'
    if (Test-Path -LiteralPath $LastAttemptManifestPath -PathType Leaf) {
        try {
            $LastAttemptManifest = Get-Content -LiteralPath $LastAttemptManifestPath -Raw | ConvertFrom-Json -Depth 10
        } catch {
            Write-Warning "Tenant Governance last-attempt manifest is invalid and ignored: $LastAttemptManifestPath. Error: $($_.Exception.Message)"
        }
    }
    $LastAttemptDiagnostics = @()
    if ($LastAttemptManifest -and -not [string]::IsNullOrWhiteSpace($LastAttemptManifest.SnapshotId) -and $LastAttemptManifest.SnapshotId -ne $SnapshotManifest.SnapshotId) {
        $LastAttemptDiagnostics = @($LastAttemptManifest.Diagnostics)
    }

    # Reports must not turn structurally duplicated resources into duplicated policies/findings.
    # Validate immutable identities and manifest counts here as well as in the workflow so direct,
    # interactive report generation has the same safety boundary.
    $SnapshotIdentityPaths = @{}
    $SnapshotFileCounts = @{}
    $SnapshotResourceFiles = @(Get-ChildItem -LiteralPath $ImportPath -Filter '*.json' -File -Recurse | Where-Object { -not $_.Name.StartsWith('.') -and $_.FullName -notmatch '[\\/]\.(?:staging|backup)-' })
    foreach ($SnapshotResourceFile in $SnapshotResourceFiles) {
        # -AsHashtable accepts valid Graph payloads containing empty JSON
        # property names, which ConvertFrom-Json otherwise rejects.
        try { $SnapshotResource = [System.IO.File]::ReadAllText($SnapshotResourceFile.FullName) | ConvertFrom-Json -AsHashtable -Depth 100 -ErrorAction Stop }
        catch { throw "Tenant Governance resource JSON is invalid: $($SnapshotResourceFile.FullName). Error: $($_.Exception.Message)" }
        $SnapshotResourceType = "$($SnapshotResource.resourceType)".Trim().ToLowerInvariant()
        $RelativeResourcePath = [System.IO.Path]::GetRelativePath($ImportPath, $SnapshotResourceFile.FullName) -replace '\\', '/'
        $FolderResourceType = ($RelativeResourcePath -split '/')[0].ToLowerInvariant()
        if ([string]::IsNullOrWhiteSpace($SnapshotResourceType)) {
            $SnapshotResourceType = $FolderResourceType
        } elseif ($SnapshotResourceType -ne $FolderResourceType) {
            throw "Tenant Governance resource '$RelativeResourcePath' declares resourceType '$SnapshotResourceType', which does not match its top-level folder '$FolderResourceType'."
        }
        if (-not $SnapshotFileCounts.ContainsKey($SnapshotResourceType)) { $SnapshotFileCounts[$SnapshotResourceType] = 0 }
        $SnapshotFileCounts[$SnapshotResourceType]++
        $SnapshotResourceId = "$($SnapshotResource.properties.Id)".Trim().ToLowerInvariant()
        if ([string]::IsNullOrWhiteSpace($SnapshotResourceId)) { continue }
        $SnapshotIdentity = "$SnapshotResourceType|$SnapshotResourceId"
        if ($SnapshotIdentityPaths.ContainsKey($SnapshotIdentity)) {
            throw "Tenant Governance snapshot contains duplicate canonical identity '$SnapshotIdentity' in '$($SnapshotIdentityPaths[$SnapshotIdentity])' and '$($SnapshotResourceFile.FullName)'. Capture a complete snapshot or remove the structural duplication before generating reports."
        }
        $SnapshotIdentityPaths[$SnapshotIdentity] = $SnapshotResourceFile.FullName
    }
    $ManifestCountProperties = @($SnapshotManifest.PublishedResourceTypeCounts.PSObject.Properties)
    $ManifestCountResourceTypes = @($ManifestCountProperties | ForEach-Object { $_.Name.ToLowerInvariant() })
    foreach ($CountProperty in $ManifestCountProperties) {
        $CountResourceType = $CountProperty.Name.ToLowerInvariant()
        $ActualFileCount = if ($SnapshotFileCounts.ContainsKey($CountResourceType)) { $SnapshotFileCounts[$CountResourceType] } else { 0 }
        if ([int]$CountProperty.Value -ne $ActualFileCount) {
            throw "Tenant Governance manifest count for '$CountResourceType' is $($CountProperty.Value), but $ActualFileCount resource file(s) exist. Refusing to generate a potentially misleading report."
        }
    }
    foreach ($CountResourceType in $SnapshotFileCounts.Keys) {
        if ($ManifestCountResourceTypes -notcontains $CountResourceType) {
            throw "Tenant Governance resource type '$CountResourceType' has $($SnapshotFileCounts[$CountResourceType]) file(s) but no PublishedResourceTypeCounts manifest entry. Refusing to generate a potentially misleading report."
        }
    }
    $IsCompleteSnapshot = $SnapshotManifest.IsComplete -eq $true -and $SnapshotManifest.SnapshotJobStatus -in @('completed', 'succeeded')
    $IsSupportedPartialSnapshot = $SnapshotManifest.SnapshotJobStatus -eq 'partiallySuccessful' -and @($SnapshotManifest.ResourceTypeStates).Count -gt 0
    if (-not $IsCompleteSnapshot -and -not ($AllowPartialSnapshot -and $IsSupportedPartialSnapshot)) {
        throw "Tenant Governance snapshot manifest does not describe a successful complete capture (status: '$($SnapshotManifest.SnapshotJobStatus)'). Use -AllowPartialSnapshot to explicitly analyze a mixed snapshot with stale resource types."
    }
    if (-not $IsCompleteSnapshot) {
        Write-Warning "Generating Configuration Analyzer data from a partial Tenant Governance snapshot. Stale resource types: $(@($SnapshotManifest.StaleResourceTypes) -join ', ')."
    }
    $CapturedDateTime = if ($SnapshotManifest.CapturedDateTime -is [datetime]) {
        [datetimeoffset]$SnapshotManifest.CapturedDateTime
    } elseif ($SnapshotManifest.CapturedDateTime -is [datetimeoffset]) {
        $SnapshotManifest.CapturedDateTime
    } else {
        $ParsedCapturedDateTime = [datetimeoffset]::MinValue
        if ([datetimeoffset]::TryParse([string]$SnapshotManifest.CapturedDateTime, [ref]$ParsedCapturedDateTime)) {
            $ParsedCapturedDateTime
        }
    }
    if ($null -eq $CapturedDateTime) {
        throw "Tenant Governance snapshot manifest has no valid CapturedDateTime: $ManifestPath."
    }
    $SnapshotAgeHours = ((Get-Date).ToUniversalTime() - $CapturedDateTime.UtcDateTime).TotalHours
    if (-not $AllowStaleSnapshot -and $SnapshotAgeHours -gt $MaxSnapshotAgeHours) {
        throw "Tenant Governance snapshot is $([math]::Round($SnapshotAgeHours, 1)) hour(s) old, exceeding the $MaxSnapshotAgeHours-hour limit. Run a new snapshot or use -AllowStaleSnapshot explicitly."
    }

    if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
        throw "git was not found on PATH. The Configuration Analyzer reads historic Tenant Governance snapshots from the git history, so git is required."
    }

    $GitRoot = (git -C $RepoRoot rev-parse --show-toplevel 2>$null)
    if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($GitRoot)) {
        throw "$RepoRoot is not inside a git working copy. The Configuration Analyzer requires the Tenant Governance snapshots to be tracked in git history."
    }
    $GitRoot = $GitRoot.Trim()

    # Git-relative path (forward slashes) of the snapshot folder, used as the pathspec
    # for `git log` / `git ls-tree`.
    $ImportPathFull = [System.IO.Path]::GetFullPath($ImportPath)
    $GitRootFull = [System.IO.Path]::GetFullPath($GitRoot)
    # Compare with a trailing separator so a sibling folder such as <root>-2 does not pass as
    # being inside <root>.
    if (-not ($ImportPathFull.Equals($GitRootFull, [System.StringComparison]::OrdinalIgnoreCase) -or
            $ImportPathFull.StartsWith($GitRootFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase))) {
        throw "ImportPath ($ImportPath) is not inside the git working copy ($GitRootFull)."
    }
    $RelPath = $ImportPathFull.Substring($GitRootFull.Length).TrimStart('\', '/') -replace '\\', '/'
    if ([string]::IsNullOrWhiteSpace($RelPath)) {
        throw "ImportPath resolved to the git working copy root; expected a subfolder such as TenantGovernance/Snapshots."
    }

    # Snapshot state/manifest files are bookkeeping (see
    # Save-EntraOpsTenantGovernanceSnapshotJson), not captured resources.
    $ExcludedFileNames = @('.PendingSnapshotJob.json', '.SnapshotManifest.json', '.LastAttemptManifest.json')

    # ---- Enumerate commits that touched the snapshot folder ------------------------
    $logArgs = @('-C', $GitRoot, 'log', "--format=%H%x1f%cI%x1f%s")
    if ($TimeRangeInDays) {
        $since = (Get-Date).ToUniversalTime().AddDays(-1 * [Math]::Abs($TimeRangeInDays)).ToString('o')
        $logArgs += "--since=$since"
    }
    $logArgs += @('--', $RelPath)

    $logOutput = & git @logArgs
    if ($LASTEXITCODE -ne 0) {
        throw "git log failed with exit code $LASTEXITCODE while enumerating history of $RelPath."
    }

    $commits = @($logOutput | Where-Object { $_ } | ForEach-Object {
            $parts = $_ -split "`u{1f}"
            [pscustomobject]@{ Sha = $parts[0]; Date = [datetimeoffset]::Parse($parts[1]); Subject = $parts[2] }
        }) | Sort-Object -Property Date

    # A partial snapshot intentionally commits only its manifest, preserving the last known-good
    # resources. Do not present that unchanged tree as a historical capture at the partial job time.
    $commits = @($commits | Where-Object {
            $manifestAtCommit = & git -C $GitRoot show "$($_.Sha):$RelPath/.SnapshotManifest.json" 2>$null
            if ($LASTEXITCODE -ne 0 -or -not $manifestAtCommit) { return $true }
            try {
                $commitManifest = ($manifestAtCommit -join "`n") | ConvertFrom-Json -Depth 10
                return -not ($commitManifest.IsComplete -eq $false -or $commitManifest.SnapshotJobStatus -eq 'partiallySuccessful')
            } catch {
                return $true
            }
        })

    if (-not $commits) {
        Write-Warning "No commits touching $RelPath were found$(if ($TimeRangeInDays) { " in the last $TimeRangeInDays day(s)" }). The dataset will only contain the current working tree state (if any)."
    }
    Write-Verbose "Found $($commits.Count) commit(s) touching $RelPath."

    # ---- Thin commits down to the requested snapshot interval ----------------------
    if ($null -ne $SnapshotIntervalComponents -and $commits.Count -gt 2) {
        $thinned = [System.Collections.Generic.List[object]]::new()
        $lastKeptDate = $null
        for ($ci = 0; $ci -lt $commits.Count; $ci++) {
            $commit = $commits[$ci]
            $isLast = ($ci -eq $commits.Count - 1)
            if ($null -eq $lastKeptDate -or $isLast -or $commit.Date -ge (Add-SnapshotInterval -Date $lastKeptDate -Interval $SnapshotIntervalComponents)) {
                $thinned.Add($commit)
                $lastKeptDate = $commit.Date
            }
        }
        Write-Verbose "Snapshot interval '$SnapshotInterval' reduced $($commits.Count) commit(s) to $($thinned.Count) snapshot(s)."
        $commits = $thinned
    }

    # ---- Build one snapshot (file path + blob hash listing) per commit -------------
    $snapshots = [System.Collections.Generic.List[object]]::new()
    foreach ($commit in $commits) {
        # -z: NUL-separated entries, so file names with special characters parse reliably.
        $lsTree = & git -C $GitRoot ls-tree -r -z $commit.Sha -- $RelPath
        if ($LASTEXITCODE -ne 0) {
            Write-Verbose "Skipped commit $($commit.Sha): git ls-tree failed."
            continue
        }
        $resources = [System.Collections.Generic.List[object]]::new()
        foreach ($entry in ($lsTree -join "`n") -split "`0") {
            if ([string]::IsNullOrWhiteSpace($entry)) { continue }
            # "<mode> <type> <hash>\t<path>"
            $tabIdx = $entry.IndexOf("`t")
            if ($tabIdx -lt 0) { continue }
            $metaParts = $entry.Substring(0, $tabIdx) -split '\s+'
            if ($metaParts.Count -lt 3 -or $metaParts[1] -ne 'blob') { continue }
            $path = $entry.Substring($tabIdx + 1)
            if (-not $path.EndsWith('.json', [System.StringComparison]::OrdinalIgnoreCase)) { continue }
            $leaf = Split-Path -Leaf $path
            if ($ExcludedFileNames -contains $leaf) { continue }
            # Store the path relative to the snapshot folder - the app derives
            # resource type/category/name from its segments.
            $rel = $path.Substring($RelPath.Length).TrimStart('/')
            $resources.Add([ordered]@{ p = $rel; h = $metaParts[2] })
        }
        if ($resources.Count -eq 0) {
            Write-Verbose "Skipped commit $($commit.Sha): no snapshot resource files."
            continue
        }
        $snapshots.Add([ordered]@{
                commitSha  = $commit.Sha
                commitDate = $commit.Date.ToString('o')
                subject    = $commit.Subject
                hasDetail  = $false
                resources  = @($resources)
            })
    }

    # ---- Append the current working tree state when it adds information -------------
    if (-not $SkipWorkingTree -and (Test-Path -LiteralPath $ImportPath -PathType Container)) {
        $wtFiles = @(Get-ChildItem -LiteralPath $ImportPath -Recurse -File -Filter '*.json' |
                Where-Object { $ExcludedFileNames -notcontains $_.Name } | Sort-Object FullName)
        if ($wtFiles.Count -gt 0) {
            $wtResources = [System.Collections.Generic.List[object]]::new()
            # git hash-object computes the same blob hash a commit would produce, so
            # working-tree files de-duplicate against committed blob content. Chunked to
            # stay clear of command-line length limits.
            $wtHashByFile = @{}
            for ($i = 0; $i -lt $wtFiles.Count; $i += 50) {
                $chunk = @($wtFiles | Select-Object -Skip $i -First 50)
                $hashes = & git -C $GitRoot hash-object -- @($chunk.FullName)
                if ($LASTEXITCODE -ne 0) { throw "git hash-object failed while hashing working tree snapshot files." }
                $hashes = @($hashes)
                for ($j = 0; $j -lt $chunk.Count; $j++) { $wtHashByFile[$chunk[$j].FullName] = $hashes[$j] }
            }
            foreach ($file in $wtFiles) {
                $rel = $file.FullName.Substring($ImportPathFull.Length).TrimStart('\', '/') -replace '\\', '/'
                $wtResources.Add([ordered]@{ p = $rel; h = $wtHashByFile[$file.FullName] })
            }

            # Only append when it differs from the newest committed snapshot.
            $differs = $true
            if ($snapshots.Count -gt 0) {
                $newest = $snapshots[$snapshots.Count - 1]
                $newestMap = @{}
                foreach ($r in $newest.resources) { $newestMap[$r.p] = $r.h }
                if ($newestMap.Count -eq $wtResources.Count) {
                    $differs = $false
                    foreach ($r in $wtResources) {
                        if ($newestMap[$r.p] -ne $r.h) { $differs = $true; break }
                    }
                }
            }
            if ($differs) {
                $snapshots.Add([ordered]@{
                        commitSha  = '(working tree)'
                        commitDate = (Get-Date).ToUniversalTime().ToString('o')
                        subject    = 'Uncommitted working tree state'
                        hasDetail  = $false
                        resources  = @($wtResources)
                        workingTree = $true
                    })
                Write-Verbose "Appended working tree state with $($wtResources.Count) resource file(s)."
            }
        }
    }

    # ---- Pick which snapshots embed full resource content ---------------------------
    $detailIdx = [System.Collections.Generic.HashSet[int]]::new()
    if ($snapshots.Count -gt 0) {
        if ($MaxDetailedSnapshots -le 0 -or $snapshots.Count -le $MaxDetailedSnapshots) {
            for ($i = 0; $i -lt $snapshots.Count; $i++) { [void]$detailIdx.Add($i) }
        } else {
            for ($i = 0; $i -lt $MaxDetailedSnapshots; $i++) {
                $pos = if ($MaxDetailedSnapshots -eq 1) { $snapshots.Count - 1 } else { [Math]::Round($i * ($snapshots.Count - 1) / ($MaxDetailedSnapshots - 1)) }
                [void]$detailIdx.Add([int]$pos)
            }
        }
    }

    # ---- Load the unique blob contents referenced by the detailed snapshots ---------
    # De-duplicated by git blob hash: an unchanged resource shared by 20 snapshots is
    # embedded exactly once.
    $blobs = [ordered]@{}
    $wtContentByHash = @{}
    $SkippedBlobCount = 0
    foreach ($idx in ($detailIdx | Sort-Object)) {
        $snap = $snapshots[$idx]
        $snap.hasDetail = $true
        $isWorkingTree = $snap.Contains('workingTree')
        foreach ($r in $snap.resources) {
            if ($blobs.Contains($r.h)) { continue }
            try {
                if ($isWorkingTree) {
                    $raw = Get-Content -LiteralPath (Join-Path $ImportPath $r.p) -Raw
                } elseif ($wtContentByHash.ContainsKey($r.h)) {
                    $raw = $wtContentByHash[$r.h]
                } else {
                    $raw = (& git -C $GitRoot cat-file blob $r.h) -join "`n"
                    if ($LASTEXITCODE -ne 0) { throw "git cat-file failed for blob $($r.h)." }
                }
                # Snapshot JSON can contain an empty property name (for example in
                # cross-tenant access policy resources). PSCustomObject parsing rejects
                # that valid JSON shape, while hashtable parsing preserves it.
                $blobs[$r.h] = $raw | ConvertFrom-Json -AsHashtable
            } catch {
                Write-Verbose "Could not load/parse blob $($r.h) ($($r.p)): $($_.Exception.Message)"
                $blobs[$r.h] = $null
                $SkippedBlobCount++
            }
        }
    }

    Write-Verbose "Embedded $($blobs.Count) unique resource content blob(s) for $($detailIdx.Count) detailed snapshot(s); $SkippedBlobCount could not be parsed."

    $ResolvedGroupTiers = [ordered]@{}
    $ResolvedDeviceTiers = [ordered]@{}
    $ResolvedPolicyGroupIds = [ordered]@{}
    if ($ResolveGroupMembersForPrivilegedAssets) {
        $PrivilegedEamPath = Join-Path $RepoRoot 'PrivilegedEAM'
        if (-not (Test-Path -LiteralPath $PrivilegedEamPath -PathType Container)) {
            Write-Warning "Group membership resolution was requested, but the Privileged EAM export was not found at $PrivilegedEamPath."
        } else {
            # Rank values are purely relational (comparisons/sorting) - canonical Enterprise Access
            # Model order: ControlPlane < ManagementPlane < WorkloadPlane < UserAccess.
            $TierRank = @{ ControlPlane = 0; ManagementPlane = 1; WorkloadPlane = 2; UserAccess = 3 }
            $TierByObjectId = @{}
            $PrivilegedEamObjects = Get-EntraOpsPrivilegedEamDashboardObjects -ImportPath $PrivilegedEamPath
            foreach ($Object in $PrivilegedEamObjects) {
                $ObjectId = "$($Object.objectId)"
                if (-not $ObjectId) { continue }
                $ObjectTierNames = @("$($Object.objectAdminTierLevelName)") +
                    @($Object.classification | ForEach-Object { "$($_.adminTierLevelName)" }) +
                    @($Object.roleAssignments | ForEach-Object { $_.classification } | ForEach-Object { "$($_.adminTierLevelName)" })
                foreach ($TierName in $ObjectTierNames | Where-Object { $TierRank.ContainsKey($_) }) {
                    if (-not $TierByObjectId.ContainsKey($ObjectId) -or $TierRank[$TierName] -lt $TierRank[$TierByObjectId[$ObjectId]]) {
                        $TierByObjectId[$ObjectId] = $TierName
                    }
                }
            }

            foreach ($Object in $PrivilegedEamObjects) {
                $TierName = "$($Object.objectAdminTierLevelName)"
                if (-not $TierRank.ContainsKey($TierName)) { continue }
                foreach ($DeviceId in @($Object.ownedDevices) + @($Object.associatedPawDevice) | Where-Object { $_ }) {
                    $DeviceId = "$DeviceId"
                    if (-not $ResolvedDeviceTiers.Contains($DeviceId) -or $TierRank[$TierName] -lt $TierRank[$ResolvedDeviceTiers[$DeviceId].tierName]) {
                        $ResolvedDeviceTiers[$DeviceId] = [ordered]@{
                            tierName    = $TierName
                            displayName = $DeviceId
                        }
                    }
                }
            }

            $IncludedGroupReferences = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            $AssignmentPolicyIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($Snapshot in @($snapshots | Where-Object { $_.hasDetail })) {
                foreach ($Resource in @($Snapshot.resources)) {
                    $Blob = $blobs[$Resource.h]
                    if ($null -eq $Blob -or $null -eq $Blob.properties) { continue }
                    $ResourceType = "$($Blob.resourceType)".ToLowerInvariant()
                    if ($ResourceType -eq 'microsoft.entra.conditionalaccesspolicy') {
                        foreach ($Group in @($Blob.properties.IncludeGroups)) {
                            if ($Group) { [void]$IncludedGroupReferences.Add("$Group") }
                        }
                    } elseif ($ResourceType.StartsWith('microsoft.entra.authenticationmethodpolicy') -and $ResourceType -ne 'microsoft.entra.authenticationmethodpolicy') {
                        foreach ($Target in @($Blob.properties.IncludeTargets)) {
                            if ($null -ne $Target -and $Target.Id) { [void]$IncludedGroupReferences.Add("$($Target.Id)") }
                        }
                    } elseif ($ResourceType -eq 'microsoft.entra.entitlementmanagementaccesspackageassignmentpolicy') {
                        $PolicyId = "$($Blob.properties.Id)"
                        if ($PolicyId) { [void]$AssignmentPolicyIds.Add($PolicyId) }
                    }
                }
            }

            foreach ($PolicyId in $AssignmentPolicyIds) {
                try {
                    $Policy = Invoke-EntraOpsMsGraphQuery -Uri "/beta/identityGovernance/entitlementManagement/accessPackageAssignmentPolicies/$PolicyId" -OutputType PSObject -ThrowOnFailure
                    $PolicyGroupIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
                    $UserSets = @($Policy.requestorSettings.allowedRequestors) + @($Policy.requestApprovalSettings.approvalStages | ForEach-Object { $_.primaryApprovers })
                    foreach ($UserSet in $UserSets) {
                        if ("$($UserSet.'@odata.type')" -eq '#microsoft.graph.groupMembers' -and $UserSet.id) {
                            [void]$PolicyGroupIds.Add("$($UserSet.id)")
                            [void]$IncludedGroupReferences.Add("$($UserSet.id)")
                        }
                    }
                    if ($PolicyGroupIds.Count -gt 0) { $ResolvedPolicyGroupIds[$PolicyId] = @($PolicyGroupIds) }
                } catch {
                    Write-Warning "Could not resolve requestor and approver groups for access package assignment policy ${PolicyId}: $($_.Exception.Message)"
                }
            }

            foreach ($GroupReference in $IncludedGroupReferences) {
                if ($GroupReference -in @('All', 'None', 'GuestsOrExternalUsers', 'All_Users')) { continue }
                $Groups = @()
                if ($GroupReference -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
                    try {
                        $Groups = @(Invoke-EntraOpsMsGraphQuery -Method Get -Uri "/v1.0/groups/${GroupReference}?`$select=id,displayName" -OutputType PSObject -ThrowOnFailure)
                    } catch {
                        Write-Warning "Could not resolve included group id '${GroupReference}': $($_.Exception.Message)"
                        continue
                    }
                } else {
                    # OData string-literal escaping first ('' for '), then percent-encoding so
                    # characters like & / # / % cannot truncate the query string (which would
                    # silently drop the $filter and enumerate every group in the tenant).
                    $EscapedReference = ConvertTo-EntraOpsODataStringLiteral -Value $GroupReference
                    try {
                        $Groups = @(Invoke-EntraOpsMsGraphQuery -Method Get -Uri "/v1.0/groups?`$filter=displayName eq '$EscapedReference'&`$select=id,displayName" -OutputType PSObject -ThrowOnFailure)
                    } catch {
                        Write-Warning "Could not resolve included group '$GroupReference': $($_.Exception.Message)"
                        continue
                    }
                }

                foreach ($Group in $Groups | Where-Object { $_.Id }) {
                    try {
                        $Members = @(Get-EntraOpsPrivilegedTransitiveGroupMember -GroupObjectId $Group.Id | Where-Object { $null -ne $_ })
                        $PrivilegedMembers = @($Members | Where-Object { $TierByObjectId.ContainsKey("$($_.Id)") })
                        $BestTier = @($PrivilegedMembers | ForEach-Object { $TierByObjectId["$($_.Id)"] } | Sort-Object { $TierRank[$_] } | Select-Object -First 1)[0]
                        if (-not $BestTier) { $BestTier = 'UserAccess' }
                        $TierNames = @($PrivilegedMembers | ForEach-Object { $TierByObjectId["$($_.Id)"] } | Sort-Object { $TierRank[$_] } -Unique)
                        if ($TierNames.Count -eq 0) { $TierNames = @('UserAccess') }
                        $ResolvedGroupTiers[$Group.Id] = [ordered]@{
                            tierName              = $BestTier
                            tierNames             = $TierNames
                            displayName           = "$($Group.DisplayName)"
                            totalMembers          = $Members.Count
                            privilegedMemberCount = $PrivilegedMembers.Count
                            members               = @($PrivilegedMembers | ForEach-Object { [ordered]@{ id = "$($_.Id)"; displayName = "$($_.DisplayName)"; tierName = $TierByObjectId["$($_.Id)"] } })
                        }
                    } catch {
                        Write-Warning "Could not resolve transitive members of included group '$($Group.DisplayName)': $($_.Exception.Message)"
                    }
                }
            }
            Write-Verbose "Resolved $($ResolvedGroupTiers.Count) included group(s) for Privileged Assets."
        }
    }

    # Compact manifest extract instead of the full raw manifest: enough for the app to
    # surface the health of the analyzed snapshot (partial captures with stale resource
    # types) without embedding manifest internals nothing reads.
    $SnapshotHealth = [ordered]@{
        status             = if ($null -ne $SnapshotManifest.SnapshotJobStatus) { "$($SnapshotManifest.SnapshotJobStatus)" } else { $null }
        capturedDateTime   = if ($null -ne $CapturedDateTime) { $CapturedDateTime.ToString('o') } else { $null }
        isComplete         = $SnapshotManifest.IsComplete -eq $true
        staleResourceTypes = @($SnapshotManifest.StaleResourceTypes | Where-Object { $_ } | ForEach-Object { "$_" })
        resourceTypeStates = @($SnapshotManifest.ResourceTypeStates | ForEach-Object {
                [ordered]@{
                    resourceType        = "$($_.ResourceType)"
                    status              = "$($_.Status)"
                    retainedResourceCount = if ($null -ne $_.RetainedResourceCount) { [int]$_.RetainedResourceCount } else { 0 }
                    publishedSnapshotId = if ($_.PublishedSnapshotId) { "$($_.PublishedSnapshotId)" } else { $null }
                    publishedSource     = if ($_.PublishedSource) { "$($_.PublishedSource)" } else { $null }
                    attemptSnapshotId   = if ($_.AttemptSnapshotId) { "$($_.AttemptSnapshotId)" } else { $null }
                }
            })
        diagnostics         = @(
            @($SnapshotManifest.Diagnostics | ForEach-Object {
                    [ordered]@{
                        resourceType    = "$($_.ResourceType)"
                        errorCategory   = "$($_.ErrorCategory)"
                        errorCode       = if ($_.ErrorCode) { "$($_.ErrorCode)" } else { $null }
                        occurrences     = [int]$_.Occurrences
                        message         = "$($_.Message)"
                        remediationHint = "$($_.RemediationHint)"
                        attemptSnapshotId = $null
                    }
                })
            @($LastAttemptDiagnostics | ForEach-Object {
                    [ordered]@{
                        resourceType    = "$($_.ResourceType)"
                        errorCategory   = "$($_.ErrorCategory)"
                        errorCode       = if ($_.ErrorCode) { "$($_.ErrorCode)" } else { $null }
                        occurrences     = [int]$_.Occurrences
                        message         = "[Last attempt $($LastAttemptManifest.SnapshotId)] $($_.Message)"
                        remediationHint = "$($_.RemediationHint)"
                        attemptSnapshotId = "$($LastAttemptManifest.SnapshotId)"
                    }
                })
        )
        errorDetailsUnavailable = $SnapshotManifest.ErrorDetailsUnavailable -eq $true
    }

    $payload = [ordered]@{
        tenantName       = Get-EntraOpsReportingTenantName -RepoRoot $RepoRoot
        generatedAt      = (Get-Date).ToUniversalTime().ToString('o')
        snapshotFolder   = $RelPath
        snapshotHealth   = $SnapshotHealth
        timeRangeInDays  = $TimeRangeInDays
        snapshotInterval = $SnapshotInterval
        snapshots        = @($snapshots)
        blobs            = $blobs
        resolvedGroupTiers = $ResolvedGroupTiers
        resolvedPolicyGroupIds = $ResolvedPolicyGroupIds
        resolvedDeviceTiers = $ResolvedDeviceTiers
        pimRequestFlowExcludedRiskFlags = $PimRequestFlowExcludedRiskFlags
        accessPackageFlowExcludedRiskFlags = $AccessPackageFlowExcludedRiskFlags
        conditionalAccessAnalysisExcludedFindings = $ConditionalAccessAnalysisExcludedFindings
        eidscaExcludedFindings = $EidscaExcludedFindings
    }

    $json = $payload | ConvertTo-Json -Depth 25 -Compress
    $content = "// Auto-generated by New-EntraOpsTenantGovernanceConfigurationAnalyzerData - do not edit by hand.`n" +
    "window.ENTRAOPS_CONFIGANALYZER_DATA = $json;`n"

    if ($PSCmdlet.ShouldProcess($OutFile, 'Write Configuration Analyzer dataset')) {
        Save-EntraOpsReportDataFile -Content $content -LiteralPath $OutFile

        Write-Host "Snapshots written: $($snapshots.Count)"
        Write-Host "Detailed snapshots: $($detailIdx.Count)"
        Write-Host "Unique content blobs: $($blobs.Count)"
        if ($SkippedBlobCount -gt 0) {
            Write-Warning "$SkippedBlobCount content blob(s) could not be parsed; affected resources have no property-level diff data."
        }
        Write-Host "Wrote $OutFile"
    }

    if ($PassThru) { $payload }
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDhqYHAd6N4lFdv
# 6xl2UDmywA3yIJWwIdtf1dHn48XZD6CCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# 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
# ARUwLwYJKoZIhvcNAQkEMSIEIPDgTg1QRaMEDKt1WTOFbcIPlWDNOo2qVjmEwMZA
# 0+ZJMA0GCSqGSIb3DQEBAQUABIIBgJ7k/jcQoShoBCGdOyFJuzT4A16EntB8R3Tg
# 2IEvjpQg0JTVVo/q/K5ofnHW2j6hu+o8QOd7ZFwADWRNIDccBV9HnVxvUyrwG6zR
# tzBB4xPhQnZqpXJEeMd8cnuCTNkF5/nlY5LQbFUWU0sgwpsqMAE+JHWQUz93aprS
# qQceQ+W+NMGBTGuiX7whbQPsKd1e08X+pZBBp0+1vvvSGcPWAkzkm3QDZyOiSx1Q
# pko8hWvAsiHE3q9Jsogz4XXDK7c/wo1K2ML+3fxeev5D7EgqVGIsb7BFxDh6W+xJ
# fM4bXS437M+lrPyM0+Wc8HETnBs/ARPvtB+2tYUNGQqkCcGHXRVmUSYN6IPHsIg4
# WdcDdkyKDLW+ZNzOL2Hdcblzi82481/ltQu0RJZ+ZDqWB+R2TCTD9MFSK+DH2Hqv
# o+T/mdLQDZ90kdUql7cdAB/ZIdECfK2dEik+KaSt8qQmPpEOpwekTS1dLNyeWx+Q
# hs5xLnK7DrVNd6jzHK9jp4O5bLzDe6GCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQxNTZaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDDwGgSanj9DVFe66Ub6TEbbuNzM5r+RgtDS
# 4oyY+iyDdw1gXPAj/KbU5w9PyWnx5tMwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAcNfh9RNJgN7pcofWxH1leSU+JkwuJ5ATBQbbxxAHAbWUCk4S
# z5ScKbS6kH5RlKhst2DSWXs/8p5lMXZ1IYziDN8hKpHe2U5e9sLpLAQi5qJVTBfX
# zUP5fT5xWNvp5vg3d/6vSRtvFsQvfaO2tARahtJj6ZynDY9ADGdJGvKa0mdU23QQ
# Usde8lQ1dbXIoWhNV3nOWRBU3N5v4PivaKSXNuOzrQGVEI1TJ0iUwHqc1nUGrQjS
# ixskUVd8YEVXfGCFUOSOL6ESr/3BH+zEBlskr+SZW6j9udtblc9+q54yPoGOlsMG
# tiYORCYccyx1HXIwzmHFNHlzeKwszZNCJUypgAVJBiJ7j+3WAA6oNRiPRlhnT7D1
# ghU7fKfHawR1vCCsF0w0uw2bHbomBGHJqHPc3SAjiV2kZJP3Afl91GwxPVKZS8Bv
# OD1ElBFKcG/mt/5VcsBZmjMLR6vHcWZTGpyvnmNB29n6ESTIUw8988Y8tprwvaw8
# xEo7OSQI0xZNa/JsjLYAqIRMKrqlRUFkC23OcoMWvNas/ocTcNXHjxSA3vAOez3w
# b9OISzwt1fnZ7mjco0BhjkFyS9QZyubfIee5uq/Rcu0kFrgWkNmCbOCUtAOzy1SY
# mSJlrpEHdp2yT18ryRHYfKM0nJRitXT0zKWcD42JOogtBdc7kJlyLSrbQsg=
# SIG # End signature block