Public/Reportings/New-EntraOpsPrivilegedAssetsData.ps1

<#
.SYNOPSIS
    Generate (refresh) the embedded dataset for the EntraOps Privileged Assets static web app.

.DESCRIPTION
    Builds one inventory record per privileged object (users, groups, service principals and
    applications incl. all object sub types) from the Privileged EAM export and merges the object
    across all RBAC systems. The inventory focuses on object details that are not visible in the
    role-assignment centric views: owners, sponsors, owned objects and devices, identity parent,
    associated work account and PAW device, administrative units, restricted management and the
    object tier compared with the classification of its role assignments.

    Related object IDs are resolved against the export first. IDs outside the export are resolved
    to display name and object type through Microsoft Graph (directoryObjects/getByIds) when
    -ResolveRelatedObjectIds is enabled; unresolved IDs stay visible as raw IDs.

    The dataset also embeds the Custom Security Attribute field names (for the PowerShell script
    generator of the app) and the current Object Classification File entries (so the app can edit
    the file). Generates data/privileged-assets-data.js exposing `window.ENTRAOPS_PRIVILEGED_ASSETS_DATA`.

.PARAMETER RepoRoot
    Path to the EntraOps repository root that contains the PrivilegedEAM export folder and
    EntraOpsConfig.json. Defaults to the repository this module lives in.

.PARAMETER ImportPath
    Folder with the Privileged EAM export. Defaults to <RepoRoot>/PrivilegedEAM.

.PARAMETER AppRoot
    Path to the PrivilegedAssets app folder. Defaults to Reports/PrivilegedAssets.

.PARAMETER OutFile
    Output file. Defaults to <AppRoot>/data/privileged-assets-data.js.

.PARAMETER ConfigFilePath
    EntraOpsConfig.json used for the Custom Security Attribute and Object Classification File
    settings. Defaults to <RepoRoot>/EntraOpsConfig.json.

.PARAMETER ResolveRelatedObjectIds
    Resolve related object IDs outside the export through Microsoft Graph. Defaults to the
    PrivilegedAssets.ResolveRelatedObjectIds config setting, or $true.

.PARAMETER MaesterTestResultPath
    Location of the Maester test results used to enrich the inventory with failed Maester tests. Accepts
    a Maester result JSON file, the file path without the .json extension, or a folder; for a folder the
    latest TestResults-<yyyy-MM-dd-HHmmss>.json is used. Failed tests and the affected objects
    (AffectedObjects, or AssetInventory in older Maester versions) are matched to the privileged objects by
    object id, app id of app registrations or user principal name. Optional: without the parameter, or if no
    result file is found, the Maester integration is skipped (a warning is shown for an explicit path that
    does not exist).

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

.EXAMPLE
    New-EntraOpsPrivilegedAssetsData

    Regenerates the Privileged Assets dataset from the PrivilegedEAM folder of this repository.

.EXAMPLE
    New-EntraOpsPrivilegedAssetsData -ResolveRelatedObjectIds $false -WhatIf

    Shows what would be generated without Microsoft Graph lookups and without changing any files.
#>


function New-EntraOpsPrivilegedAssetsData {

    [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.String]$ConfigFilePath,

        [Parameter(Mandatory = $false)]
        [System.Boolean]$ResolveRelatedObjectIds = $true,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [System.String]$MaesterTestResultPath = '',

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

    $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 = if (-not [string]::IsNullOrWhiteSpace($Global:EntraOpsBaseFolder)) { $Global:EntraOpsBaseFolder } else { Split-Path -Parent $ModuleRoot }

    if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = $RepositoryRoot }
    if ([string]::IsNullOrWhiteSpace($AppRoot)) { $AppRoot = Join-Path $RepoRoot 'Reports/PrivilegedAssets' }
    if (-not (Test-Path -LiteralPath $AppRoot -PathType Container)) {
        throw "Privileged Assets app folder not found: $AppRoot. Run Initialize-EntraOpsWorkspace -Content Reports -Force or Install-EntraOpsReportingFolder -Force to install or repair the reporting apps in the working folder (use -Ref to match the module version). Generated report data is preserved. If you passed -AppRoot, check that path instead."
    }
    if ([string]::IsNullOrWhiteSpace($ImportPath)) { $ImportPath = Join-Path $RepoRoot 'PrivilegedEAM' }
    if ([string]::IsNullOrWhiteSpace($OutFile)) { $OutFile = Join-Path $AppRoot 'data/privileged-assets-data.js' }
    if ([string]::IsNullOrWhiteSpace($ConfigFilePath)) { $ConfigFilePath = Join-Path $RepoRoot 'EntraOpsConfig.json' }

    if (-not (Test-Path -LiteralPath $ImportPath)) {
        throw "Privileged EAM import path not found: $ImportPath. Run Save-EntraOpsPrivilegedEAMJson first or pass -ImportPath."
    }

    $Config = $null
    if (Test-Path -LiteralPath $ConfigFilePath -PathType Leaf) {
        try {
            $Config = Get-Content -LiteralPath $ConfigFilePath -Raw | ConvertFrom-Json
        } catch {
            Write-Warning "Failed to read ${ConfigFilePath}: $($_.Exception.Message). Default Custom Security Attribute names are used and the Object Classification File is not loaded."
        }
    }
    if (-not $PSBoundParameters.ContainsKey('ResolveRelatedObjectIds') -and $null -ne $Config.PrivilegedAssets -and $null -ne $Config.PrivilegedAssets.ResolveRelatedObjectIds) {
        $ResolveRelatedObjectIds = [bool]$Config.PrivilegedAssets.ResolveRelatedObjectIds
    }

    function Get-PropValue {
        param($Object, [string]$Name)
        if ($null -eq $Object) { return $null }
        $Property = $Object.PSObject.Properties[$Name]
        if ($null -ne $Property) { return $Property.Value }
        return $null
    }

    function ConvertTo-StringArray {
        param($Value)
        if ($null -eq $Value) { return @() }
        return @(@($Value) | Where-Object { $null -ne $_ -and "$_" -ne '' } | ForEach-Object { "$_" })
    }

    function Get-ConfigValue {
        param($Section, [string]$Name, [string]$Default)
        $Value = "$(Get-PropValue $Section $Name)"
        if ([string]::IsNullOrWhiteSpace($Value)) { return $Default }
        return $Value
    }

    $TierRank = @{ ControlPlane = 0; ManagementPlane = 1; WorkloadPlane = 2; UserAccess = 3; Unclassified = 4 }
    $GuidPattern = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'
    $RelationshipProperties = [ordered]@{
        owners                = 'Owners'
        sponsors              = 'Sponsors'
        ownedObjects          = 'OwnedObjects'
        ownedDevices          = 'OwnedDevices'
        associatedWorkAccount = 'AssociatedWorkAccount'
        associatedPawDevice   = 'AssociatedPawDevice'
    }

    $Files = @(Get-ChildItem -Path $ImportPath -Directory -ErrorAction SilentlyContinue |
        ForEach-Object { Get-ChildItem -Path $_.FullName -Filter '*.json' -File } |
        Sort-Object FullName)
    if ($Files.Count -eq 0) {
        Write-Warning "No Privileged EAM JSON files found under $ImportPath (expected <RbacSystem>/<RbacSystem>.json). Generating an empty dataset."
    }

    $ObjectsById = [ordered]@{}
    foreach ($File in $Files) {
        $RoleSystem = Split-Path -Leaf (Split-Path -Parent $File.FullName)
        try {
            $Data = [System.IO.File]::ReadAllText($File.FullName) | ConvertFrom-Json
        } catch {
            throw "Failed to read or parse Privileged EAM export file '$($File.FullName)' (RBAC system '$RoleSystem'): $($_.Exception.Message)"
        }

        foreach ($Source in @($Data)) {
            if ($null -eq $Source) { continue }
            $ObjectId = "$(Get-PropValue $Source 'ObjectId')".ToLowerInvariant()
            if ($ObjectId -notmatch $GuidPattern) { continue }

            if (-not $ObjectsById.Contains($ObjectId)) {
                $TierName = "$(Get-PropValue $Source 'ObjectAdminTierLevelName')"
                if ([string]::IsNullOrWhiteSpace($TierName)) { $TierName = 'Unclassified' }
                $DisplayName = "$(Get-PropValue $Source 'ObjectDisplayName')"
                $ObjectsById[$ObjectId] = [ordered]@{
                    objectId                      = $ObjectId
                    objectType                    = "$(Get-PropValue $Source 'ObjectType')".ToLowerInvariant()
                    objectSubType                 = "$(Get-PropValue $Source 'ObjectSubType')"
                    displayName                   = $(if ($DisplayName) { $DisplayName } else { $ObjectId })
                    userPrincipalName             = "$(Get-PropValue $Source 'ObjectUserPrincipalName')"
                    objectTenantId                = "$(Get-PropValue $Source 'ObjectTenantId')"
                    tierLevel                     = "$(Get-PropValue $Source 'ObjectAdminTierLevel')"
                    tierName                      = $TierName
                    onPremSynchronized            = [bool](Get-PropValue $Source 'OnPremSynchronized')
                    restrictedManagementByRAG     = [bool](Get-PropValue $Source 'RestrictedManagementByRAG')
                    restrictedManagementByAadRole = [bool](Get-PropValue $Source 'RestrictedManagementByAadRole')
                    restrictedManagementByRMAU    = [bool](Get-PropValue $Source 'RestrictedManagementByRMAU')
                    identityParent                = "$(Get-PropValue $Source 'IdentityParent')"
                    administrativeUnits           = [System.Collections.Generic.List[object]]::new()
                    roleSystems                   = [System.Collections.Generic.List[string]]::new()
                    assignments                   = [System.Collections.Generic.List[object]]::new()
                }
                foreach ($Key in $RelationshipProperties.Keys) {
                    $ObjectsById[$ObjectId][$Key] = [System.Collections.Generic.List[string]]::new()
                }
            }
            $Object = $ObjectsById[$ObjectId]
            if (-not $Object.roleSystems.Contains($RoleSystem)) { $Object.roleSystems.Add($RoleSystem) }

            # RBAC systems can carry sparser records for the same object: OR flags and fill empty fields.
            foreach ($Flag in @('OnPremSynchronized', 'RestrictedManagementByRAG', 'RestrictedManagementByAadRole', 'RestrictedManagementByRMAU')) {
                $Key = $Flag.Substring(0, 1).ToLowerInvariant() + $Flag.Substring(1)
                $Object[$Key] = $Object[$Key] -or [bool](Get-PropValue $Source $Flag)
            }
            $FieldMap = [ordered]@{ objectSubType = 'ObjectSubType'; userPrincipalName = 'ObjectUserPrincipalName'; objectTenantId = 'ObjectTenantId'; identityParent = 'IdentityParent'; tierLevel = 'ObjectAdminTierLevel' }
            foreach ($Key in $FieldMap.Keys) {
                if ([string]::IsNullOrWhiteSpace($Object[$Key])) { $Object[$Key] = "$(Get-PropValue $Source $FieldMap[$Key])" }
            }
            if ($Object.displayName -eq $ObjectId -and "$(Get-PropValue $Source 'ObjectDisplayName')") { $Object.displayName = "$(Get-PropValue $Source 'ObjectDisplayName')" }
            $SourceTierName = "$(Get-PropValue $Source 'ObjectAdminTierLevelName')"
            if ($Object.tierName -eq 'Unclassified' -and $TierRank.ContainsKey($SourceTierName)) {
                $Object.tierName = $SourceTierName
                $Object.tierLevel = "$(Get-PropValue $Source 'ObjectAdminTierLevel')"
            }

            foreach ($Key in $RelationshipProperties.Keys) {
                foreach ($RelatedId in (ConvertTo-StringArray (Get-PropValue $Source $RelationshipProperties[$Key]))) {
                    $NormalizedId = $RelatedId.ToLowerInvariant()
                    if (-not $Object[$Key].Contains($NormalizedId)) { $Object[$Key].Add($NormalizedId) }
                }
            }
            foreach ($AdministrativeUnit in @(Get-PropValue $Source 'AssignedAdministrativeUnits')) {
                $AdministrativeUnitId = "$(Get-PropValue $AdministrativeUnit 'id')".ToLowerInvariant()
                if (-not $AdministrativeUnitId -or @($Object.administrativeUnits | Where-Object { $_.id -eq $AdministrativeUnitId }).Count -gt 0) { continue }
                $Object.administrativeUnits.Add([ordered]@{ id = $AdministrativeUnitId; displayName = "$(Get-PropValue $AdministrativeUnit 'displayName')" })
            }

            foreach ($RoleAssignment in @(Get-PropValue $Source 'RoleAssignments')) {
                if ($null -eq $RoleAssignment) { continue }
                $ClassificationTiers = @(@(Get-PropValue $RoleAssignment 'Classification') | ForEach-Object { "$(Get-PropValue $_ 'AdminTierLevelName')" } | Where-Object { $TierRank.ContainsKey($_) })
                $AssignmentTier = @($ClassificationTiers | Sort-Object { $TierRank[$_] } | Select-Object -First 1)[0]
                if (-not $AssignmentTier) { $AssignmentTier = 'Unclassified' }
                $InstanceId = "$(Get-PropValue $RoleAssignment 'RoleAssignmentInstanceId')"
                if (-not $InstanceId) { $InstanceId = Get-EntraOpsRoleAssignmentInstanceId -RoleSystem $RoleSystem -RoleAssignment $RoleAssignment }
                $Object.assignments.Add([ordered]@{
                        id                 = $InstanceId
                        roleSystem         = $RoleSystem
                        roleDefinitionName = "$(Get-PropValue $RoleAssignment 'RoleDefinitionName')"
                        roleDefinitionId   = "$(Get-PropValue $RoleAssignment 'RoleDefinitionId')"
                        scopeName          = "$(Get-PropValue $RoleAssignment 'RoleAssignmentScopeName')"
                        scopeId            = "$(Get-PropValue $RoleAssignment 'RoleAssignmentScopeId')"
                        assignmentType     = "$(Get-PropValue $RoleAssignment 'RoleAssignmentType')"
                        assignmentSubType  = "$(Get-PropValue $RoleAssignment 'RoleAssignmentSubType')"
                        pimAssignmentType  = "$(Get-PropValue $RoleAssignment 'PIMAssignmentType')"
                        transitiveBy       = "$(Get-PropValue $RoleAssignment 'TransitiveByObjectDisplayName')"
                        tierName           = $AssignmentTier
                        services           = @(@(Get-PropValue $RoleAssignment 'Classification') | ForEach-Object { "$(Get-PropValue $_ 'Service')" } | Where-Object { $_ } | Select-Object -Unique)
                    })
            }
        }
    }

    $TenantCounts = @{}
    foreach ($Object in $ObjectsById.Values) {
        if (-not $Object.objectTenantId -or ($Object.objectType -eq 'user' -and $Object.objectSubType -eq 'Guest')) { continue }
        $TenantCounts[$Object.objectTenantId] = [int]$TenantCounts[$Object.objectTenantId] + 1
    }
    $HomeTenantId = ($TenantCounts.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 1).Key

    $ObjectsByAppId = @{}
    foreach ($Object in $ObjectsById.Values) {
        if ($Object.objectType -in @('serviceprincipal', 'application') -and $Object.userPrincipalName -match $GuidPattern) {
            $ObjectsByAppId[$Object.userPrincipalName.ToLowerInvariant()] = $Object.objectId
        }
    }

    $Objects = [System.Collections.Generic.List[object]]::new()
    $RelatedObjects = [ordered]@{}
    $UnresolvedIds = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($Object in $ObjectsById.Values) {
        $Summary = [ordered]@{ total = $Object.assignments.Count; eligible = 0; active = 0; byTier = [ordered]@{}; bySystem = [ordered]@{}; highestTierName = 'Unclassified' }
        foreach ($TierName in @('ControlPlane', 'ManagementPlane', 'WorkloadPlane', 'UserAccess', 'Unclassified')) { $Summary.byTier[$TierName] = 0 }
        foreach ($Assignment in $Object.assignments) {
            $Summary.byTier[$Assignment.tierName]++
            $Summary.bySystem[$Assignment.roleSystem] = [int]$Summary.bySystem[$Assignment.roleSystem] + 1
            if ($Assignment.pimAssignmentType -eq 'Eligible') { $Summary.eligible++ } else { $Summary.active++ }
            if ($TierRank[$Assignment.tierName] -lt $TierRank[$Summary.highestTierName]) { $Summary.highestTierName = $Assignment.tierName }
        }
        $Object['assignmentSummary'] = $Summary
        $Object['isForeign'] = [bool]($HomeTenantId -and $Object.objectTenantId -and $Object.objectTenantId -ne $HomeTenantId)
        $Object['restrictedManagement'] = if ($Object.objectType -in @('serviceprincipal', 'application')) {
            'Not available'
        } elseif ($Object.objectType -eq 'group' -and $Object.objectSubType -eq 'Role-assignable' -and $Object.restrictedManagementByRMAU) {
            'Conflict'
        } elseif ($Object.restrictedManagementByAadRole -or $Object.restrictedManagementByRAG -or $Object.restrictedManagementByRMAU) {
            'Applied'
        } else {
            'Not applied'
        }

        $RelatedIds = @($RelationshipProperties.Keys | ForEach-Object { $Object[$_] })
        if ($Object.identityParent) {
            $ParentKey = $Object.identityParent.ToLowerInvariant()
            $Object.identityParent = $ParentKey
            if ($ObjectsByAppId.ContainsKey($ParentKey) -and -not $RelatedObjects.Contains($ParentKey)) {
                $ParentObject = $ObjectsById[$ObjectsByAppId[$ParentKey]]
                $RelatedObjects[$ParentKey] = [ordered]@{ objectId = $ParentObject.objectId; displayName = $ParentObject.displayName; objectType = $ParentObject.objectType; objectSubType = $ParentObject.objectSubType; tierName = $ParentObject.tierName; source = 'PrivilegedEAM' }
            } else {
                $RelatedIds += $ParentKey
            }
        }
        foreach ($RelatedId in $RelatedIds) {
            if ($RelatedObjects.Contains($RelatedId)) { continue }
            if ($ObjectsById.Contains($RelatedId)) {
                $RelatedObject = $ObjectsById[$RelatedId]
                $RelatedObjects[$RelatedId] = [ordered]@{ objectId = $RelatedId; displayName = $RelatedObject.displayName; objectType = $RelatedObject.objectType; objectSubType = $RelatedObject.objectSubType; tierName = $RelatedObject.tierName; source = 'PrivilegedEAM' }
            } elseif ($RelatedId -match $GuidPattern) {
                $UnresolvedIds.Add($RelatedId) | Out-Null
            }
        }
        $Objects.Add($Object)
    }

    if ($ResolveRelatedObjectIds -and $UnresolvedIds.Count -gt 0) {
        Write-Verbose "Resolving $($UnresolvedIds.Count) related object id(s) outside the Privileged EAM export via Microsoft Graph..."
        $IdsToResolve = @($UnresolvedIds | Sort-Object)
        $GraphBatchSize = 999 # directoryObjects/getByIds accepts up to 1000 IDs per request.
        for ($Index = 0; $Index -lt $IdsToResolve.Count; $Index += $GraphBatchSize) {
            $IdBatch = @($IdsToResolve[$Index..([Math]::Min($Index + $GraphBatchSize - 1, $IdsToResolve.Count - 1))])
            $Body = @{ ids = $IdBatch } | ConvertTo-Json
            try {
                foreach ($Resolved in @(Invoke-EntraOpsMsGraphQuery -Method POST -Uri '/v1.0/directoryObjects/getByIds' -Body $Body -OutputType PSObject)) {
                    $ResolvedId = "$(Get-PropValue $Resolved 'id')".ToLowerInvariant()
                    if (-not $ResolvedId) { continue }
                    $ResolvedType = "$(Get-PropValue $Resolved '@odata.type')" -replace '^#microsoft\.graph\.', ''
                    $RelatedObjects[$ResolvedId] = [ordered]@{
                        objectId          = $ResolvedId
                        displayName       = "$(Get-PropValue $Resolved 'displayName')"
                        objectType        = $ResolvedType.ToLowerInvariant()
                        userPrincipalName = "$(Get-PropValue $Resolved 'userPrincipalName')"
                        operatingSystem   = "$(Get-PropValue $Resolved 'operatingSystem')"
                        isCompliant       = Get-PropValue $Resolved 'isCompliant'
                        isManaged         = Get-PropValue $Resolved 'isManaged'
                        trustType         = "$(Get-PropValue $Resolved 'trustType')"
                        tierName          = ''
                        source            = 'Microsoft Graph'
                    }
                }
            } catch {
                Write-Warning "Failed to resolve $($IdBatch.Count) related object id(s) via Microsoft Graph: $($_.Exception.Message)"
            }
        }
    }

    # Maester failed tests: TestResults JSON (tests) joined with its AffectedObjects (assets) per privileged object.
    $MaesterPayload = $null
    if (-not [string]::IsNullOrWhiteSpace($MaesterTestResultPath)) {
        $MaesterFile = $null
        if (Test-Path -LiteralPath $MaesterTestResultPath -PathType Container) {
            $MaesterFile = Get-ChildItem -LiteralPath $MaesterTestResultPath -File -Filter 'TestResults-*.json' |
            Where-Object { $_.Name -match '^TestResults-\d{4}-\d{2}-\d{2}-\d{6}\.json$' } |
            Sort-Object -Property Name -Descending | Select-Object -First 1
        } elseif (Test-Path -LiteralPath $MaesterTestResultPath -PathType Leaf) {
            $MaesterFile = Get-Item -LiteralPath $MaesterTestResultPath
        } elseif (Test-Path -LiteralPath "$MaesterTestResultPath.json" -PathType Leaf) {
            $MaesterFile = Get-Item -LiteralPath "$MaesterTestResultPath.json"
        }

        if ($null -eq $MaesterFile) {
            $MaesterMessage = "No Maester test result found at '$MaesterTestResultPath'. Maester findings are not added to the Privileged Assets dataset."
            if ($PSBoundParameters.ContainsKey('MaesterTestResultPath')) { Write-Warning $MaesterMessage } else { Write-Verbose $MaesterMessage }
        } else {
            Write-Host "Maester test result: $($MaesterFile.FullName)"
            try {
                $MaesterResult = [System.IO.File]::ReadAllText($MaesterFile.FullName) | ConvertFrom-Json
            } catch {
                throw "Failed to read or parse Maester test result '$($MaesterFile.FullName)': $($_.Exception.Message)"
            }

            function Limit-MaesterText {
                param($Value, [int]$Length = 12000)
                $Text = "$Value"
                if ($Text.Length -le $Length) { return $Text }
                return $Text.Substring(0, $Length) + "`n`n... (truncated, see the Maester report for the full result)"
            }

            $MaesterAssets = @(@(Get-PropValue $MaesterResult 'AffectedObjects') + @(Get-PropValue $MaesterResult 'AssetInventory') | Where-Object { $null -ne $_ })
            $FailedTests = @{}
            foreach ($MaesterTest in @(Get-PropValue $MaesterResult 'Tests')) {
                if ($null -ne $MaesterTest -and "$(Get-PropValue $MaesterTest 'Result')" -eq 'Failed') { $FailedTests["$(Get-PropValue $MaesterTest 'Id')"] = $MaesterTest }
            }

            $ObjectIdsByAppId = @{}
            $ObjectIdsByUpn = @{}
            foreach ($Object in $ObjectsById.Values) {
                if ($Object.objectType -eq 'application' -and $Object.userPrincipalName -match $GuidPattern) { $ObjectIdsByAppId[$Object.userPrincipalName.ToLowerInvariant()] = $Object.objectId }
                if ($Object.objectType -eq 'user' -and $Object.userPrincipalName -like '*@*') { $ObjectIdsByUpn[$Object.userPrincipalName.ToLowerInvariant()] = $Object.objectId }
            }

            $UsedTestIds = [System.Collections.Generic.HashSet[string]]::new()
            $MatchedObjectIds = [System.Collections.Generic.HashSet[string]]::new()
            foreach ($Asset in $MaesterAssets) {
                $FailedIds = @(ConvertTo-StringArray (Get-PropValue $Asset 'Tests') | Where-Object { $FailedTests.ContainsKey($_) })
                if ($FailedIds.Count -eq 0) { continue }

                $AssetId = "$(Get-PropValue $Asset 'Id')".ToLowerInvariant()
                $AssetUpn = "$(Get-PropValue $Asset 'UserPrincipalName')".ToLowerInvariant()
                $TargetId = $null
                if ($ObjectsById.Contains($AssetId)) { $TargetId = $AssetId }
                elseif ("$(Get-PropValue $Asset 'Type')" -eq 'AppRegistration' -and $ObjectIdsByAppId.ContainsKey($AssetId)) { $TargetId = $ObjectIdsByAppId[$AssetId] }
                elseif ($AssetUpn -and $ObjectIdsByUpn.ContainsKey($AssetUpn)) { $TargetId = $ObjectIdsByUpn[$AssetUpn] }
                if (-not $TargetId) { continue }

                if (-not $ObjectsById[$TargetId].Contains('maesterAssets')) { $ObjectsById[$TargetId]['maesterAssets'] = [System.Collections.Generic.List[object]]::new() }
                $ObjectsById[$TargetId]['maesterAssets'].Add([ordered]@{
                        system            = "$(Get-PropValue $Asset 'System')"
                        anchorKind        = "$(Get-PropValue $Asset 'AnchorKind')"
                        type              = "$(Get-PropValue $Asset 'Type')"
                        id                = "$(Get-PropValue $Asset 'Id')"
                        uniqueId          = "$(Get-PropValue $Asset 'UniqueId')"
                        displayName       = "$(Get-PropValue $Asset 'DisplayName')"
                        userPrincipalName = "$(Get-PropValue $Asset 'UserPrincipalName')"
                        portalLink        = "$(Get-PropValue $Asset 'PortalLink')"
                        sources           = @(ConvertTo-StringArray (Get-PropValue $Asset 'Sources'))
                        tests             = @($FailedIds)
                    })
                $MatchedObjectIds.Add($TargetId) | Out-Null
                foreach ($FailedId in $FailedIds) { $UsedTestIds.Add($FailedId) | Out-Null }
            }

            $MaesterTests = [ordered]@{}
            foreach ($TestId in @($UsedTestIds | Sort-Object)) {
                $MaesterTest = $FailedTests[$TestId]
                $Detail = Get-PropValue $MaesterTest 'ResultDetail'
                $Severity = "$(Get-PropValue $MaesterTest 'Severity')"
                if ([string]::IsNullOrWhiteSpace($Severity)) { $Severity = "$(Get-PropValue $Detail 'Severity')" }
                $MaesterTests[$TestId] = [ordered]@{
                    index       = Get-PropValue $MaesterTest 'Index'
                    id          = $TestId
                    title       = "$(Get-PropValue $MaesterTest 'Title')"
                    name        = "$(Get-PropValue $MaesterTest 'Name')"
                    severity    = $Severity
                    result      = "$(Get-PropValue $MaesterTest 'Result')"
                    tags        = @(ConvertTo-StringArray (Get-PropValue $MaesterTest 'Tag'))
                    helpUrl     = "$(Get-PropValue $MaesterTest 'HelpUrl')"
                    block       = "$(Get-PropValue $MaesterTest 'Block')"
                    service     = "$(Get-PropValue $Detail 'Service')"
                    description = Limit-MaesterText (Get-PropValue $Detail 'TestDescription')
                    testResult  = Limit-MaesterText (Get-PropValue $Detail 'TestResult')
                }
            }

            $ExecutedAt = $null
            try { $ExecutedAt = ([datetime](Get-PropValue $MaesterResult 'ExecutedAt')).ToUniversalTime().ToString('o') } catch { Write-Verbose "Maester ExecutedAt could not be parsed: $($_.Exception.Message)" }
            $MaesterTenantId = "$(Get-PropValue $MaesterResult 'TenantId')"
            if ($HomeTenantId -and $MaesterTenantId -and $MaesterTenantId -ne $HomeTenantId) {
                Write-Warning "The Maester test result belongs to tenant $MaesterTenantId, but the Privileged EAM export belongs to $HomeTenantId."
            }
            $MaesterPayload = [ordered]@{
                sourceFile          = $MaesterFile.Name
                executedAt          = $ExecutedAt
                tenantId            = $MaesterTenantId
                tenantName          = "$(Get-PropValue $MaesterResult 'TenantName')"
                totals              = [ordered]@{
                    total   = [int](Get-PropValue $MaesterResult 'TotalCount')
                    failed  = [int](Get-PropValue $MaesterResult 'FailedCount')
                    passed  = [int](Get-PropValue $MaesterResult 'PassedCount')
                    skipped = [int](Get-PropValue $MaesterResult 'SkippedCount')
                }
                affectedObjectCount = $MaesterAssets.Count
                matchedObjectCount  = $MatchedObjectIds.Count
                tests               = $MaesterTests
            }
            Write-Verbose "Maester: $($MaesterTests.Count) failed test(s) on $($MatchedObjectIds.Count) privileged object(s) from $($MaesterFile.FullName)"
        }
    }

    $CsaConfig = Get-PropValue $Config 'CustomSecurityAttributes'
    $AlternateConfig = Get-PropValue $Config 'AlternateObjectTierLevelAttributes'
    $FileConfig = Get-PropValue $Config 'ObjectClassificationFile'
    $FilePath = Get-ConfigValue $FileConfig 'FilePath' './Classification/ObjectClassification.json'
    $FileEntries = @()
    $FileError = ''
    try {
        $FileEntries = @((Import-EntraOpsObjectClassificationFile -FilePath $FilePath -RootFolder $RepoRoot -WarningAction SilentlyContinue).Values | Sort-Object ObjectId | ForEach-Object {
                [ordered]@{
                    objectId           = $_.ObjectId
                    objectType         = $_.ObjectType
                    objectDisplayName  = $_.ObjectDisplayName
                    adminTierLevelName = $_.AdminTierLevelName
                    justification      = $_.Justification
                }
            })
    } catch {
        $FileError = $_.Exception.Message
        Write-Warning "Object Classification File could not be loaded for Privileged Assets: $FileError"
    }

    $SortedRelatedObjects = [ordered]@{}
    foreach ($Key in @($RelatedObjects.Keys | Sort-Object)) { $SortedRelatedObjects[$Key] = $RelatedObjects[$Key] }

    $RepoRootFull = (Resolve-Path -LiteralPath $RepoRoot).Path
    $Payload = [ordered]@{
        tenantName             = Get-EntraOpsReportingTenantName -RepoRoot $RepoRoot
        generatedAt            = (Get-Date).ToUniversalTime().ToString('o')
        generatedFrom          = @($Files | ForEach-Object {
                if ($_.FullName.StartsWith($RepoRootFull)) { $_.FullName.Substring($RepoRootFull.Length).TrimStart('\', '/') -replace '\\', '/' } else { $_.Name }
            })
        homeTenantId           = "$HomeTenantId"
        maester                = $MaesterPayload
        classificationSettings = [ordered]@{
            customSecurityAttributes           = [ordered]@{
                enabledFor                         = [ordered]@{
                    user             = Test-EntraOpsCustomSecurityAttributeClassificationEnabled -ObjectType User -Enabled (Get-PropValue $CsaConfig 'Enabled') -ObjectClassificationFile $FileConfig -AlternateObjectTierLevelAttributes $AlternateConfig
                    servicePrincipal = Test-EntraOpsCustomSecurityAttributeClassificationEnabled -ObjectType ServicePrincipal -Enabled (Get-PropValue $CsaConfig 'Enabled') -ObjectClassificationFile $FileConfig -AlternateObjectTierLevelAttributes $AlternateConfig
                    application      = Test-EntraOpsCustomSecurityAttributeClassificationEnabled -ObjectType Application -Enabled (Get-PropValue $CsaConfig 'Enabled') -ObjectClassificationFile $FileConfig -AlternateObjectTierLevelAttributes $AlternateConfig
                }
                userAttributeSet                   = Get-ConfigValue $CsaConfig 'PrivilegedUserAttribute' 'privilegedUser'
                userTierLevelAttribute             = Get-ConfigValue $CsaConfig 'PrivilegedUserAdminTierLevelAttribute' 'adminTierLevel'
                userTierNameAttribute              = Get-ConfigValue $CsaConfig 'PrivilegedUserAdminTierLevelNameAttribute' 'adminTierLevelName'
                servicePrincipalAttributeSet       = Get-ConfigValue $CsaConfig 'PrivilegedServicePrincipalAttribute' 'privilegedWorkloadIdentity'
                servicePrincipalTierLevelAttribute = Get-ConfigValue $CsaConfig 'PrivilegedServicePrincipalAdminTierLevelAttribute' 'adminTierLevel'
                servicePrincipalTierNameAttribute  = Get-ConfigValue $CsaConfig 'PrivilegedServicePrincipalAdminTierLevelNameAttribute' 'adminTierLevelName'
            }
            alternateObjectTierLevelAttributes = [ordered]@{
                user             = Test-EntraOpsAlternateObjectTierLevelEnabled -ObjectType User -AlternateObjectTierLevelAttributes $AlternateConfig
                servicePrincipal = Test-EntraOpsAlternateObjectTierLevelEnabled -ObjectType ServicePrincipal -AlternateObjectTierLevelAttributes $AlternateConfig
                group            = Test-EntraOpsAlternateObjectTierLevelEnabled -ObjectType Group -AlternateObjectTierLevelAttributes $AlternateConfig
            }
            objectClassificationFile           = [ordered]@{
                enabled  = [bool](Get-PropValue $FileConfig 'Enabled')
                filePath = $FilePath
                error    = $FileError
                entries  = @($FileEntries)
            }
        }
        objects                = @($Objects | Sort-Object { $_.displayName })
        relatedObjects         = $SortedRelatedObjects
    }

    $Json = $Payload | ConvertTo-Json -Depth 10 -Compress
    $Content = "// Auto-generated by New-EntraOpsPrivilegedAssetsData - do not edit by hand.`n" +
    "window.ENTRAOPS_PRIVILEGED_ASSETS_DATA = $Json;`n"

    if ($PSCmdlet.ShouldProcess($OutFile, 'Write Privileged Assets dataset')) {
        Save-EntraOpsReportDataFile -Content $Content -LiteralPath $OutFile
        Write-Host "Privileged objects: $($Objects.Count)"
        Write-Host "Related objects: $($SortedRelatedObjects.Count) resolved, $([Math]::Max(0, $UnresolvedIds.Count - @($SortedRelatedObjects.Values | Where-Object { $_.source -eq 'Microsoft Graph' }).Count)) unresolved"
        if ($null -ne $MaesterPayload) { Write-Host "Maester findings: $($MaesterPayload.tests.Count) failed test(s) on $($MaesterPayload.matchedObjectCount) privileged object(s) ($($MaesterPayload.sourceFile))" }
        Write-Host "Wrote $OutFile"
    }

    if ($PassThru) { $Payload }
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCC6Hu6AfKg1g4SI
# M41kmiByWV4QdAmhSH8fMCRg949PNqCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# 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
# ARUwLwYJKoZIhvcNAQkEMSIEIEceGkOHGIc4B18S0vGsnRZHspap0kxFhU0Qk0aT
# BYcTMA0GCSqGSIb3DQEBAQUABIIBgD4toHNpOzBMP7BiYp4orAsSr2QmYLQd7umj
# gpXAMhglb7Vwd6Rx9Cn2Qrh1tAmyJBTEJ225ManX6z/GtkfSpJI4XpmTq1iIYmOZ
# QJKCRTzQ1TAdXYE/W6v6BmO5E8ZakzbdNO9D35l+wa93PDhIvZeXhXkWCvfaG8X2
# qXcgwnpHA0n+dq5NsNRYH/LItcibJYefYQEhJonTvR4d7mPZP0B7YzEfrq4zcFid
# W6YlMh8OzaGo//ts75SriWYEfO1X9y+mSPgnVYiKUOY2n85Vtd8AiFUaDPvU5a3I
# 1fWBKQ8zE8SmXLLIV5efb1af/uRT/VvBJ+VcDoocF73AeMehDaAHP4enfDdgxwox
# eXCVFINQpexX+ouXFWqucaL8Jkn9e1GxVuu/IDmc4qOYcSyUxweklZo2kyVBI3LD
# Uy6vW43m53o+jetkiem63ZmuvH00Jz496q6zhsmM8AAxpsMtnP4rpwKFF82bUFfF
# 7ztD50foIVQL8bnzgiugDWm5BDRR56GCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMjIxMTQwMTlaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDAd+73T5Z4Dq8ahX9q4gRMz07dxSv/oENEE
# ZfLGXALN/NcB0PJ6jTzKPmVXp5qbdyEwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAZTjCUNDsW0Kt8s4HKuZG/uodAkSlevVs9ZpfRY/bW6/2eouw
# D38XthC958dSyok7JK8VvCmq8ZHw2mCbR0Wm095hVCGFBk9WJ91IeA+tzLGRLaPT
# Z2C93TfNg2+1mXCiCyJI7uuE5fmYF5zpvGeYhRCuLIyya2FvOEL9NRQmikja/qgB
# aBlQg1MVfNvyvxWdAOSCrckV6ujNi/vFr5CUKmtQzOINhJK6GCOf8gHNpwKBrgVu
# HVOhxfwuf9ZnQI2DlwcFSKtxJwz4mefQcEGIXQ0PPyaxQTCsrhyez0o3d4JmqJV9
# +g+vL1jMQteGgQe8CV0GJIIbMGFBA3Aj0PtIAbpqYfJPKQEC+kLEm5xP0WpHgG2P
# kak4IGyz9GIO2eSx/B1G061o/YNiKVEm/DWAAPQAPLnsVBuTthHJPd30ekxRToBY
# J3Wn/htWIo9JoOoNjHHcC17DOLJkCCNt8o/UsqoSJkijSkjbLHMxyR9UXzUO0Mck
# 7Di5U0peQzeopZj2GZ+inO3ZcocyR9YvYca6Pea5akO+zp335Z1i60gVDzw3uxgU
# uzy6onQDQZdKKkqa54AS2bd18KodED2THWXTDTl4veFDmL9x1zl378iqJzc9D+3J
# K609bASgVnlM1PuQGU7reWgBlkEOhs5LJ9ktzirfppFgmIZT3R7X+SlS0BU=
# SIG # End signature block