Public/PrivilegedAccess/Invoke-EntraOpsPrivilegedEAM.ps1

<#
.SYNOPSIS
    Run EntraOps Privileged EAM classification with an optional configuration file.

.DESCRIPTION
    Entry point that resolves tenant identity from an already-authenticated
    AzContext and MgGraph context, sets up all required global variables, downloads the
    latest classification templates from the AzurePrivilegedIAM repository, computes the
    Control Plane scope, runs Save-EntraOpsPrivilegedEAMJson, and cleans up every
    intermediate file so that only the PrivilegedEAM JSON output remains.

    Reuses the configuration loaded by Connect-EntraOps. Otherwise, looks for EntraOpsConfig.json
    in the current folder, then the EntraOps working folder. Use -ConfigFilePath to select a file
    explicitly. Command parameters override configuration values. Without a configuration, defaults apply.
    When an automatically found configuration contains AlternateObjectTierLevelAttributes filter
    expressions, a warning is written before the run because these expressions execute as PowerShell
    code. Pass trusted configuration files with -ConfigFilePath.
    The Azure and Microsoft Graph contexts must belong to the same tenant, with or without a configuration.

    Prerequisites:
    - PowerShell 7.4+ with the EntraOps module loaded.
    - An active Az context (Connect-AzAccount).
    - An active Microsoft Graph context (Connect-MgGraph).

    No EntraOpsConfig.json, no Classification folder, and no tenant-specific files are
    required beforehand. All temporary artefacts are removed after the run, except for the
    overwrite files (Classification_RoleActionOverwrites.json, Classification_RoleDefinitionOverwrites.json,
    Classification_ApiPermissionOverwrites.json and their _<Tag> variants) in the tenant-specific
    Classification folder: these are tenant customization files that
    can be authored/maintained outside of this cmdlet (e.g. via the ClassificationExplorer
    "Customize Overwrites" view, or manually) and are always preserved if present. Use
    -KeepClassificationFiles to keep all classification files for the reporting apps.
    This cmdlet disconnects from Azure and Microsoft Graph during teardown. Reconnect before
    generating reports with live Microsoft Graph enrichment, or disable the reporting object lookups.

.PARAMETER RbacSystems
    RBAC systems to classify. Defaults to Azure, EntraID, IdentityGovernance,
    DeviceManagement, and ResourceApps when no configuration supplies RbacSystems.

.PARAMETER PrivilegedObjectClassificationSource
    Source for Control Plane scope determination passed to
    Update-EntraOpsClassificationControlPlaneScope. Uses the configuration value when present.
    Otherwise defaults to "All", combining any existing
    EntraOps EAM export with live Azure Resource Graph and Exposure Management sources.

.PARAMETER ClassificationParameterScope
    Which RBAC systems receive a parameterised classification file before the run.
    Filtered automatically to the intersection with $RbacSystems. Default is
    EntraID, DeviceManagement, and Azure when no configuration supplies ClassificationParameterScope.

.PARAMETER ExportFolder
    Destination folder for PrivilegedEAM JSON output. Defaults to
    the session output folder when reusing a loaded configuration, otherwise <EntraOpsBaseFolder>/PrivilegedEAM/.

.PARAMETER ConfigFilePath
    Path to an EntraOps configuration file. Takes precedence over the configuration loaded by Connect-EntraOps.

.PARAMETER KeepClassificationFiles
    Keep the downloaded classification templates and the generated tenant-specific classification
    files instead of removing them after the run, e.g. so New-EntraOpsReportingData can show the
    classification that was used for the export in the Classification Explorer.
    Controls cleanup only, independently of the choice to reuse or refresh templates.

.PARAMETER ExistingClassificationAction
    When tenant classification files exist, Prompt (default) asks whether to Reuse local templates
    (default choice) or Regenerate using refreshed templates. This prompt is independent of -KeepClassificationFiles.
    Reuse preserves existing templates and downloads only missing templates. Regenerate refreshes templates.
    Both choices rebuild tenant classifications and Control Plane scope reasoning for the selected scope
    through Update-EntraOpsClassificationControlPlaneScope, using the preserved tenant overwrite files.
    Pass Reuse or Regenerate explicitly for unattended runs to avoid prompting.

.EXAMPLE
    Run EntraOps with all defaults from an already-authenticated session.
    Invoke-EntraOpsPrivilegedEAM

.EXAMPLE
    Run EntraOps and generate the reporting apps from the export and its classification files.
    Invoke-EntraOpsPrivilegedEAM -KeepClassificationFiles -ExistingClassificationAction Reuse
    Connect-EntraOps -AuthenticationType UserInteractive -TenantName 'contoso.onmicrosoft.com'
    New-EntraOpsReportingData
    Disconnect-EntraOps

.EXAMPLE
    Run for EntraID and Azure only, sourcing Control Plane objects from Azure Resource Graph.
    Invoke-EntraOpsPrivilegedEAM -RbacSystems @("EntraID","Azure") -PrivilegedObjectClassificationSource "PrivilegedRolesFromAzGraph"

.EXAMPLE
    Save output to a custom folder.
    Invoke-EntraOpsPrivilegedEAM -ExportFolder "C:\Output\PrivilegedEAM"

.EXAMPLE
    Use the configuration loaded by Connect-EntraOps and rebuild tenant classifications from existing templates.
    Connect-EntraOps -TenantName 'contoso.onmicrosoft.com' -ConfigFilePath './EntraOpsConfig.json'
    Invoke-EntraOpsPrivilegedEAM -KeepClassificationFiles -ExistingClassificationAction Reuse
#>

function Invoke-EntraOpsPrivilegedEAM {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [ValidateSet("Azure", "EntraID", "IdentityGovernance", "DeviceManagement", "ResourceApps", "Defender")]
        [Array]$RbacSystems = @("Azure", "EntraID", "IdentityGovernance", "DeviceManagement", "ResourceApps")
        ,
        [Parameter(Mandatory = $false)]
        [ValidateSet("All", "EntraOps", "PrivilegedObjectIds", "PrivilegedRolesFromAzGraph", "PrivilegedEdgesFromExposureManagement")]
        [string[]]$PrivilegedObjectClassificationSource = "All"
        ,
        [Parameter(Mandatory = $false)]
        [ValidateSet("EntraID", "DeviceManagement", "Azure", "Defender", "IdentityGovernance", "ResourceApps")]
        [Array]$ClassificationParameterScope = @("EntraID", "DeviceManagement", "Azure")
        ,
        [Parameter(Mandatory = $false)]
        [System.String]$ExportFolder
        ,
        [Parameter(Mandatory = $false)]
        [switch]$KeepClassificationFiles
        ,
        [Parameter(Mandatory = $false)]
        [System.String]$ConfigFilePath
        ,
        [Parameter(Mandatory = $false)]
        [ValidateSet('Prompt', 'Reuse', 'Regenerate')]
        [string]$ExistingClassificationAction = 'Prompt'
    )

    $ErrorActionPreference = "Stop"
    $Activity = "Invoke-EntraOpsPrivilegedEAM"

    # Tracks which paths were created by this cmdlet so only those are cleaned up.
    $CreatedPaths = [System.Collections.Generic.List[string]]::new()
    # Names of classification templates this run actually downloaded into a pre-existing Templates folder.
    # Stays empty unless the download phase completes, so teardown can never delete user-supplied templates.
    $DownloadedTemplates = [System.Collections.Generic.List[string]]::new()

    # Helper: create a directory only if it does not already exist; record it for cleanup.
    function New-EntraOpsDirectory {
        param (
            [string]$Path,
            [System.Collections.Generic.List[string]]$Track
        )
        if (-not (Test-Path -LiteralPath $Path)) {
            New-Item -ItemType Directory -Path $Path -Force | Out-Null
            $Track.Add($Path)
        }
    }

    try {
        $SessionConfig = Get-Variable -Name EntraOpsConfig -Scope Global -ValueOnly -ErrorAction SilentlyContinue
        $UseSessionConfig = -not $ConfigFilePath -and $null -ne $SessionConfig
        $RunConfig = $null
        $ResolvedConfigPath = $null
        if ($UseSessionConfig) {
            $RunConfig = $SessionConfig
            $ResolvedConfigPath = $__EntraOpsSession['ConfigFilePath']
        } else {
            $ConfigAutoDiscovered = -not $ConfigFilePath
            if (-not $ConfigFilePath) {
                $ConfigFilePath = @(
                    Join-Path (Get-Location -PSProvider FileSystem).ProviderPath 'EntraOpsConfig.json'
                    if (-not [string]::IsNullOrWhiteSpace($EntraOpsBaseFolder)) { Join-Path $EntraOpsBaseFolder 'EntraOpsConfig.json' }
                ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
            }
            if ($ConfigFilePath) {
                $ResolvedConfigPath = (Resolve-Path -LiteralPath $ConfigFilePath -ErrorAction Stop).ProviderPath
                $RunConfig = Get-Content -LiteralPath $ResolvedConfigPath -Raw | ConvertFrom-Json -Depth 100 -AsHashtable -NoEnumerate
                if ($RunConfig -isnot [System.Collections.IDictionary]) {
                    throw "Configuration file '$ResolvedConfigPath' must contain a JSON object."
                }
                # Warn before any AlternateObjectTierLevelAttributes filter can run: an automatically
                # discovered file (e.g. in a cloned or downloaded folder) was not explicitly chosen as trusted.
                $AlternateConfig = $RunConfig.AlternateObjectTierLevelAttributes
                $HasAlternateFilters = $null -ne $AlternateConfig -and @(foreach ($ObjectType in 'User', 'ServicePrincipal', 'Group') {
                        if (Test-EntraOpsAlternateObjectTierLevelEnabled -ObjectType $ObjectType -AlternateObjectTierLevelAttributes $AlternateConfig) {
                            foreach ($TierName in 'ControlPlane', 'ManagementPlane', 'WorkloadPlane', 'UserAccess') {
                                if (-not [string]::IsNullOrWhiteSpace([string]$AlternateConfig.$ObjectType.$TierName)) { $TierName }
                            }
                        }
                    }).Count -gt 0
                if ($ConfigAutoDiscovered -and $HasAlternateFilters) {
                    Write-Warning "Automatically loaded configuration '$ResolvedConfigPath' contains AlternateObjectTierLevelAttributes filter expressions. These expressions are executed as PowerShell code with your current sessions. Only run with configuration files you trust, and pass trusted files explicitly with -ConfigFilePath."
                }
            }
        }
        $HasConfiguration = $null -ne $RunConfig
        if ($HasConfiguration) {
            if (-not $PSBoundParameters.ContainsKey('RbacSystems') -and $RunConfig.RbacSystems) {
                $RbacSystems = $RunConfig.RbacSystems
            }
            if (-not $PSBoundParameters.ContainsKey('PrivilegedObjectClassificationSource') -and $RunConfig.AutomatedControlPlaneScopeUpdate.PrivilegedObjectClassificationSource) {
                $PrivilegedObjectClassificationSource = $RunConfig.AutomatedControlPlaneScopeUpdate.PrivilegedObjectClassificationSource
            }
            if (-not $PSBoundParameters.ContainsKey('ClassificationParameterScope') -and $null -ne $RunConfig.AutomatedControlPlaneScopeUpdate.ClassificationParameterScope) {
                $ClassificationParameterScope = $RunConfig.AutomatedControlPlaneScopeUpdate.ClassificationParameterScope
            }
        }

        # ── Banner ────────────────────────────────────────────────────────────────
        Write-Host ""
        Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
        $RunMode = if ($HasConfiguration) { 'Configured Run' } else { 'Zero-Config Run' }
        Write-Host " Invoke-EntraOpsPrivilegedEAM — $RunMode" -ForegroundColor Cyan
        if ($HasConfiguration) {
            $ConfigSource = if ($ResolvedConfigPath) { $ResolvedConfigPath } else { 'In-memory session configuration (source file path unavailable)' }
            Write-Host " Config File : $ConfigSource" -ForegroundColor Yellow
        }
        Write-Host " RBAC Systems : $($RbacSystems -join ', ')" -ForegroundColor Cyan
        Write-Host " CP Source : $($PrivilegedObjectClassificationSource -join ', ')" -ForegroundColor Cyan
        Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
        Write-Host ""

        # ── Phase 1: Validate authentication ─────────────────────────────────────
        Write-Progress -Activity $Activity -Status "Phase 1/7: Validating authentication contexts..." -PercentComplete 2

        Write-Host " [1/7] Validating authentication contexts..." -ForegroundColor Cyan

        $AzContext = Get-AzContext -ErrorAction SilentlyContinue
        if (-not $AzContext -or [string]::IsNullOrEmpty($AzContext.Tenant.Id)) {
            throw "No active Azure context found. Establish an Azure context by running Connect-AzAccount or run Connect-EntraOps before invoking this cmdlet."
        }

        $MgContext = Get-MgContext -ErrorAction SilentlyContinue
        if (-not $MgContext -or [string]::IsNullOrEmpty($MgContext.TenantId)) {
            throw "No active Microsoft Graph context found. Establish an Microsoft Graph context by running Connect-MgGraph or run Connect-EntraOps before invoking this cmdlet."
        }

        Write-Host " Azure : $($AzContext.Account.Id) @ tenant $($AzContext.Tenant.Id)" -ForegroundColor Gray
        Write-Host " Graph : $($MgContext.Account) @ tenant $($MgContext.TenantId)" -ForegroundColor Gray
        Write-Host " Status : OK" -ForegroundColor Green

        # ── Phase 2: Resolve tenant identity ─────────────────────────────────────
        Write-Progress -Activity $Activity -Status "Phase 2/7: Resolving tenant identity..." -PercentComplete 8

        Write-Host ""
        Write-Host " [2/7] Resolving tenant identity..." -ForegroundColor Cyan

        $TenantId = $AzContext.Tenant.Id
        if ([string]::IsNullOrEmpty($TenantId)) {
            $TenantId = $MgContext.TenantId
        }
        if ($HasConfiguration -and $RunConfig.TenantId -and $RunConfig.TenantId -ne $TenantId) {
            throw "Configuration tenant '$($RunConfig.TenantId)' does not match the active Azure tenant '$TenantId'. Connect-EntraOps to the configured tenant before running."
        }
        if ($MgContext.TenantId -ne $TenantId) {
            $ReconnectTarget = if ($HasConfiguration) { 'the configured tenant' } else { 'the same tenant in both contexts' }
            throw "Microsoft Graph tenant '$($MgContext.TenantId)' does not match the active Azure tenant '$TenantId'. Reconnect to $ReconnectTarget before running."
        }

        # Keep the configured tenant name so an alternate verified domain does not select a different
        # classification folder. Configurations without an ID must match a verified tenant domain.
        $TenantName = if ($HasConfiguration) { $RunConfig.TenantName }
        if ($TenantName -and -not $RunConfig.TenantId) {
            $TenantDetails = Get-AzTenant -TenantId $TenantId -ErrorAction Stop
            if ($TenantName -notin $TenantDetails.Domains) {
                throw "Configuration TenantName '$TenantName' does not match the active Azure tenant '$TenantId'."
            }
        }
        if ([string]::IsNullOrEmpty($TenantName)) {
            try {
                $TenantDetails = Get-AzTenant -TenantId $TenantId -ErrorAction Stop
                $TenantName = $TenantDetails.Domains[0]
            } catch {
                Write-Warning "Get-AzTenant failed ($($_.Exception.Message)). Falling back to Microsoft Graph organization endpoint..."
            }
        }

        # Fallback: Microsoft Graph /organization
        if ([string]::IsNullOrEmpty($TenantName)) {
            try {
                $OrgResponse = Invoke-MgGraphRequest -Uri "v1.0/organization?`$select=verifiedDomains" -OutputType PSObject -ErrorAction Stop
                $TenantName = ($OrgResponse.value[0].verifiedDomains |
                    Where-Object { $_.isDefault -eq $true } |
                    Select-Object -ExpandProperty name -First 1)
            } catch {
                throw "Unable to determine TenantName from Azure or Microsoft Graph context: $($_.Exception.Message)"
            }
        }

        if ([string]::IsNullOrEmpty($TenantName)) {
            throw "TenantName could not be resolved. Check that Get-AzTenant or Microsoft Graph /organization is accessible."
        }

        Write-Host " TenantId : $TenantId" -ForegroundColor Gray
        Write-Host " TenantName : $TenantName" -ForegroundColor Gray
        Write-Host " Status : OK" -ForegroundColor Green

        # ── Phase 3: Initialise environment ──────────────────────────────────────
        Write-Progress -Activity $Activity -Status "Phase 3/7: Initialising environment and global variables..." -PercentComplete 14

        Write-Host ""
        Write-Host " [3/7] Initialising environment and global variables..." -ForegroundColor Cyan

        if ([string]::IsNullOrEmpty($EntraOpsBaseFolder)) {
            throw "EntraOpsBaseFolder is not set. Ensure the EntraOps module is properly imported."
        }

        # Reuse configured session folders; standalone runs use the single-tenant layout.
        New-Variable -Name TenantIdContext            -Value $TenantId   -Scope Global -Force
        New-Variable -Name TenantNameContext          -Value $TenantName -Scope Global -Force
        # Same "<base>/Classification/" form as Connect-EntraOps; Join-Path would emit '\' on Windows.
        $ClassificationRoot = if ($UseSessionConfig -and -not [string]::IsNullOrWhiteSpace($DefaultFolderClassification)) {
            $DefaultFolderClassification
        } else { "$EntraOpsBaseFolder/Classification/" }
        New-Variable -Name DefaultFolderClassification -Value $ClassificationRoot -Scope Global -Force
        New-Variable -Name XdrAvdHuntingAccess         -Value (($MgContext.Scopes) -contains "ThreatHunting.Read.All") -Scope Global -Force
        # Default the query mode only when Connect-EntraOps has not set it yet, so a previously
        # chosen -UseInvokeRestMethodOnly session setting is preserved
        if (-not $__EntraOpsSession.ContainsKey('UseInvokeRestMethodOnly')) {
            $__EntraOpsSession['UseInvokeRestMethodOnly'] = [bool]$RunConfig.UseInvokeRestMethodOnly
        }
        $ManagingTenantId = if ($HasConfiguration) { [string]$RunConfig.ManagingTenantId } else { '' }
        $ManagingTenantName = if ($HasConfiguration) { [string]$RunConfig.ManagingTenantName } else { '' }
        New-Variable -Name ManagingTenantIdContext      -Value $ManagingTenantId -Scope Global -Force
        New-Variable -Name ManagingTenantNameContext    -Value $ManagingTenantName -Scope Global -Force

        $ResolvedExportFolder = if ($PSBoundParameters.ContainsKey('ExportFolder') -and -not [string]::IsNullOrEmpty($ExportFolder)) {
            $ExportFolder
        } elseif ($UseSessionConfig -and -not [string]::IsNullOrWhiteSpace($DefaultFolderClassifiedEam)) {
            $DefaultFolderClassifiedEam
        } else {
            "$EntraOpsBaseFolder/PrivilegedEAM/"
        }
        New-Variable -Name DefaultFolderClassifiedEam -Value $ResolvedExportFolder -Scope Global -Force

        # Phase 5 can consume a previous EAM export, but a zero-config first run has no such files yet.
        # Remember that state before creating the output directory so Phase 6 can bootstrap scope reasoning
        # once from the fresh export without imposing a second pass on normal subsequent runs.
        # ALL selected systems must have a prior export: a partial folder (e.g. only EntraID.json, no
        # Azure/ResourceApps data driving managed-identity tiering) still needs the bootstrap pass.
        $HadExistingEamData = @($RbacSystems | Where-Object {
                Test-Path -LiteralPath (Join-Path $ResolvedExportFolder $_ "$_.json") -PathType Leaf
            }).Count -eq @($RbacSystems).Count

        # Build a minimal default EntraOpsConfig in memory (no config file required)
        $DefaultEntraOpsConfig = @{
            TenantId                         = $TenantId
            TenantName                       = $TenantName
            RbacSystems                      = $RbacSystems
            AzureRbacClassification          = @{
                ClassifyConstrainedDelegationAlwaysAsControlPlane = $false
                UnresolvedRoleDefinitionFallbackTier              = "Unclassified"
                DeletedPrincipalAssignmentHandling                = "Filter"
            }
            AutomatedClassificationUpdate    = @{
                Classifications = @(
                    "AadResources", "AadResources.Param", "ApiPermissions",
                    "Azure", "Azure.Param",
                    "Defender",
                    "DeviceManagement", "DeviceManagement.Param",
                    "IdentityGovernance"
                )
            }
            AutomatedControlPlaneScopeUpdate = @{
                PrivilegedObjectClassificationSource = @($PrivilegedObjectClassificationSource)
                EntraOpsScopes                       = $RbacSystems
                AzureHighPrivilegedRoles             = @("Owner", "Role Based Access Control Administrator", "User Access Administrator")
                # "/" covers elevateAccess-scoped assignments; the management group path covers the
                # tenant root management group (the real highest hierarchy in ARM).
                AzureHighPrivilegedScopes            = @("/", "/providers/microsoft.management/managementgroups/$TenantId")
                ExposureCriticalityLevel             = "<1"
            }
            CustomSecurityAttributes         = @{
                PrivilegedUserAttribute             = "privilegedUser"
                PrivilegedUserPawAttribute          = "associatedSecureAdminWorkstation"
                PrivilegedServicePrincipalAttribute = "privilegedWorkloadIdentity"
                UserWorkAccountAttribute            = "associatedWorkAccount"
            }
        }
        if (-not $HasConfiguration) { $RunConfig = $DefaultEntraOpsConfig }
        New-Variable -Name EntraOpsConfig -Value $RunConfig -Scope Global -Force
        if ($ResolvedConfigPath) { $__EntraOpsSession['ConfigFilePath'] = $ResolvedConfigPath }
        New-Variable -Name EntraOpsIncludeObjectDetails -Value ([bool]$RunConfig.ConsoleOutput.IncludeObjectDetails) -Scope Global -Force

        # Construct canonical paths used throughout this run
        $ClassificationTemplates = Join-Path $ClassificationRoot "Templates"
        $ClassificationTenant = Join-Path $ClassificationRoot $TenantName

        $ExistingTenantFiles = @(if (Test-Path -LiteralPath $ClassificationTenant -PathType Container) {
            Get-ChildItem -LiteralPath $ClassificationTenant -Filter 'Classification_*.json' -File
        })
        $ReuseExistingTemplates = $ExistingClassificationAction -eq 'Reuse'
        if ($ExistingClassificationAction -eq 'Prompt' -and $ExistingTenantFiles.Count -gt 0) {
            Write-Host " Found $($ExistingTenantFiles.Count) classification file(s) for '$TenantName' in $ClassificationTenant" -ForegroundColor Yellow
            Write-Host " Reuse keeps local templates. Regenerate downloads the latest templates. Both choices refresh tenant classifications and Control Plane scopes using the preserved tenant overwrite files." -ForegroundColor Yellow
            do {
                try {
                    $Answer = Read-Host 'Reuse local classification templates or regenerate using the latest templates? [R] Reuse (default) / [G] Regenerate'
                } catch {
                    throw "Unable to prompt for existing classification files. Pass -ExistingClassificationAction Reuse or Regenerate for unattended runs. $($_.Exception.Message)"
                }
                $Answer = ([string]$Answer).Trim()
                $ValidAnswer = $Answer -in @('', 'R', 'Reuse', 'G', 'Regenerate')
                if (-not $ValidAnswer) { Write-Host ' Enter R to reuse or G to regenerate.' -ForegroundColor Yellow }
            } until ($ValidAnswer)
            $ReuseExistingTemplates = $Answer -in @('', 'R', 'Reuse')
        }

        # Ensure required directories exist; track newly created ones for cleanup
        New-EntraOpsDirectory -Path $ClassificationRoot      -Track $CreatedPaths
        New-EntraOpsDirectory -Path $ClassificationTemplates -Track $CreatedPaths
        New-EntraOpsDirectory -Path $ClassificationTenant    -Track $CreatedPaths
        New-EntraOpsDirectory -Path $ResolvedExportFolder    -Track $CreatedPaths

        Write-Host " Base folder : $EntraOpsBaseFolder" -ForegroundColor Gray
        Write-Host " Classification : $ClassificationRoot" -ForegroundColor Gray
        Write-Host " EAM output : $ResolvedExportFolder" -ForegroundColor Gray
        Write-Host " Paths created : $($CreatedPaths.Count)" -ForegroundColor Gray
        Write-Host " Status : OK" -ForegroundColor Green

        # ── Phase 4: Download latest classification files ─────────────────────────
        Write-Progress -Activity $Activity -Status "Phase 4/7: Preparing classification templates..." -PercentComplete 22

        Write-Host ""
        $TemplateStatus = if ($ReuseExistingTemplates) { 'Reusing existing classification templates and downloading missing files' } else { 'Downloading latest classification templates' }
        Write-Host " [4/7] $TemplateStatus from AzurePrivilegedIAM..." -ForegroundColor Cyan

        # Snapshot pre-existing templates so teardown can delete only what THIS run downloaded. The previous
        # "Classification_*.json" wildcard removed templates the user had downloaded or authored earlier.
        $PreExistingTemplates = @()
        if (Test-Path -LiteralPath $ClassificationTemplates -ErrorAction SilentlyContinue) {
            $PreExistingTemplates = @(Get-ChildItem -LiteralPath $ClassificationTemplates -Filter "Classification_*.json" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name)
        }

        $Classifications = if ($RunConfig.AutomatedClassificationUpdate.Classifications) {
            $RunConfig.AutomatedClassificationUpdate.Classifications
        } else { $DefaultEntraOpsConfig.AutomatedClassificationUpdate.Classifications }
        Update-EntraOpsClassificationFiles `
            -FolderClassification $ClassificationTemplates `
            -Classifications $Classifications `
            -SkipExisting:$ReuseExistingTemplates

        # Record only the templates that did not exist before this run, so teardown removes exactly those.
        if (Test-Path -LiteralPath $ClassificationTemplates -ErrorAction SilentlyContinue) {
            Get-ChildItem -LiteralPath $ClassificationTemplates -Filter "Classification_*.json" -ErrorAction SilentlyContinue |
            Where-Object { $PreExistingTemplates -notcontains $_.Name } |
            ForEach-Object { $DownloadedTemplates.Add($_.Name) | Out-Null }
        }

        Write-Host " Status : Classification templates updated." -ForegroundColor Green

        # ── Phase 5: Update Control Plane scope ───────────────────────────────────
        Write-Progress -Activity $Activity -Status "Phase 5/7: Updating Control Plane scope classification..." -PercentComplete 38

        Write-Host ""
        Write-Host " [5/7] Updating Control Plane scope classification..." -ForegroundColor Cyan

        # Always refresh tenant classifications and scope reasoning for selected systems, even when
        # local templates are reused. This also reapplies current tenant role action overwrites.
        $EffectiveClassificationScope = @($ClassificationParameterScope | Where-Object { $_ -in $RbacSystems })
        $ScopeUpdateParameters = @{}
        if ($HasConfiguration) {
            foreach ($Name in @('AzureHighPrivilegedRoles', 'AzureHighPrivilegedScopes', 'ExposureCriticalityLevel', 'PrivilegedObjectIds', 'DeviceMgmtPrivilegedTierScope', 'EntraOpsScopes')) {
                $Value = $RunConfig.AutomatedControlPlaneScopeUpdate.$Name
                if ($null -ne $Value) { $ScopeUpdateParameters[$Name] = $Value }
            }
        }
        if (-not $ScopeUpdateParameters.ContainsKey('EntraOpsScopes')) { $ScopeUpdateParameters.EntraOpsScopes = $RbacSystems }
        $ScopeUpdateParameters.PrivilegedObjectClassificationSource = $PrivilegedObjectClassificationSource
        $ScopeUpdateParameters.ClassificationParameterScope = $EffectiveClassificationScope

        if ($EffectiveClassificationScope.Count -eq 0) {
            Write-Host " No classification files require generation for the selected scope." -ForegroundColor Gray
        } else {
            Write-Host " Effective scope : $($EffectiveClassificationScope -join ', ')" -ForegroundColor Gray
            Update-EntraOpsClassificationControlPlaneScope @ScopeUpdateParameters
        }

        Write-Host " Status : Control Plane scope updated." -ForegroundColor Green

        # ── Phase 6: Save EAM JSON ────────────────────────────────────────────────
        Write-Progress -Activity $Activity -Status "Phase 6/7: Running EntraOps classification and saving EAM JSON..." -PercentComplete 62

        Write-Host ""
        Write-Host " [6/7] Running EntraOps classification and saving Privileged EAM JSON..." -ForegroundColor Cyan
        Write-Host " RbacSystems : $($RbacSystems -join ', ')" -ForegroundColor Gray
        Write-Host " Output : $ResolvedExportFolder" -ForegroundColor Gray

        Save-EntraOpsPrivilegedEAMJson -ExportFolder $ResolvedExportFolder -RbacSystems $RbacSystems

        Write-Host " Status : EAM JSON saved." -ForegroundColor Green

        if (-not $HadExistingEamData -and $EffectiveClassificationScope.Count -gt 0) {
            Write-Host ""
            Write-Host " First-run bootstrap: refreshing Control Plane scope from the fresh EAM export..." -ForegroundColor Yellow
            # Same source as the first pass: narrowing to 'EntraOps' here would drop live-source-only
            # objects (e.g. XSPM-critical assets without classified roles) from the Control Plane scope.
            Update-EntraOpsClassificationControlPlaneScope @ScopeUpdateParameters
            # The Graph cache was populated moments ago by the first pass - only the classification
            # files changed, so the re-export can reuse it instead of re-collecting everything.
            Save-EntraOpsPrivilegedEAMJson -ExportFolder $ResolvedExportFolder -RbacSystems $RbacSystems -UseCache $true
            Write-Host " Status : Scope reasoning and EAM JSON refreshed from first-run output." -ForegroundColor Green
        }

        # ── Summary ───────────────────────────────────────────────────────────────
        Write-Progress -Activity $Activity -Status "Complete." -PercentComplete 100

        $OutputFiles = Get-ChildItem -Path $ResolvedExportFolder -Filter "*.json" -Recurse -ErrorAction SilentlyContinue
        $OutputCount = if ($OutputFiles) { @($OutputFiles).Count } else { 0 }

        Write-Host ""
        Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Green
        Write-Host " Invoke-EntraOpsPrivilegedEAM — Complete" -ForegroundColor Green
        Write-Host " Tenant : $TenantName ($TenantId)" -ForegroundColor White
        Write-Host " Output : $ResolvedExportFolder" -ForegroundColor White
        Write-Host " JSON files : $OutputCount" -ForegroundColor White
        Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Green
        Write-Host ""

        Start-Sleep -Milliseconds 400
        Write-Progress -Activity $Activity -Completed
    } catch {
        Write-Progress -Activity $Activity -Completed
        Write-Host ""
        Write-Host " [ERROR] Invoke-EntraOpsPrivilegedEAM failed at:" -ForegroundColor Red
        Write-Host " $($_.InvocationInfo.PositionMessage)" -ForegroundColor DarkRed
        Write-Host " $($_.Exception.Message)" -ForegroundColor Red
        Write-Host ""
        throw
    } finally {
        # ── Always: remove classification artefacts and disconnect ────────────────
        # Null-guards protect against early failures before path variables were set.
        Write-Host " [Teardown] Removing classification artefacts and disconnecting..." -ForegroundColor Cyan

        if ($KeepClassificationFiles) {
            Write-Host " Kept : classification files in $ClassificationRoot (-KeepClassificationFiles)" -ForegroundColor DarkGray
        }

        # Remove the generated tenant-specific classification folder, but preserve the overwrite files
        # (Classification_RoleActionOverwrites.json, Classification_RoleDefinitionOverwrites.json,
        # Classification_ApiPermissionOverwrites.json and their _<Tag> variants):
        # these are tenant customization files that are commonly authored/maintained outside of this
        # cmdlet (e.g. via the ClassificationExplorer "Customize Overwrites" view, or manually) and
        # must survive a zero-config run, unlike the classification files generated by this run.
        # Only clean up a tenant folder THIS run created. Without the $CreatedPaths gate (already used for the
        # Templates and Classification root below) a zero-config run against an existing configured repo
        # permanently deleted the user's generated Classification_Azure.json / ScopeReasoning_*.json - on the
        # success path as well as on failure.
        if (-not $KeepClassificationFiles -and -not [string]::IsNullOrEmpty($ClassificationTenant) -and ($CreatedPaths -contains $ClassificationTenant) -and (Test-Path -LiteralPath $ClassificationTenant -ErrorAction SilentlyContinue)) {
            $PreservedTenantFiles = @(Get-ChildItem -LiteralPath $ClassificationTenant -File -Force -ErrorAction SilentlyContinue |
                Where-Object { Test-EntraOpsClassificationOverwriteFileName -Name $_.Name } | Select-Object -ExpandProperty Name)
            Get-ChildItem -LiteralPath $ClassificationTenant -Force -ErrorAction SilentlyContinue |
            Where-Object { $PreservedTenantFiles -notcontains $_.Name } |
            Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
            $PreservedTenantFileList = if ($PreservedTenantFiles.Count -gt 0) { $PreservedTenantFiles -join ', ' } else { 'no overwrite files' }
            Write-Host " Cleaned : $ClassificationTenant (preserved $PreservedTenantFileList)" -ForegroundColor DarkGray

            # Remove the tenant folder itself only if it ends up empty (i.e. no preserved overwrite files remain).
            if (-not (Get-ChildItem -LiteralPath $ClassificationTenant -Force -ErrorAction SilentlyContinue)) {
                Remove-Item -LiteralPath $ClassificationTenant -Recurse -Force -ErrorAction SilentlyContinue
                Write-Host " Removed : empty folder $ClassificationTenant" -ForegroundColor DarkGray
            }
        }

        # Templates folder: remove whole folder if we created it, otherwise only downloaded files
        if (-not $KeepClassificationFiles -and -not [string]::IsNullOrEmpty($ClassificationTemplates)) {
            if (($CreatedPaths -contains $ClassificationTemplates) -and (Test-Path -LiteralPath $ClassificationTemplates -ErrorAction SilentlyContinue)) {
                Remove-Item -LiteralPath $ClassificationTemplates -Recurse -Force -ErrorAction SilentlyContinue
                Write-Host " Removed : $ClassificationTemplates" -ForegroundColor DarkGray
            } elseif (Test-Path -LiteralPath $ClassificationTemplates -ErrorAction SilentlyContinue) {
                # Pre-existing folder: remove only the templates this run downloaded, never ones that were
                # already present (they may be user-maintained or downloaded by an earlier configured run).
                # $DownloadedTemplates stays empty if the run failed before the download phase.
                $RemovedTemplateCount = 0
                foreach ($TemplateName in $DownloadedTemplates) {
                    $TemplatePath = Join-Path -Path $ClassificationTemplates -ChildPath $TemplateName
                    if (Test-Path -LiteralPath $TemplatePath -ErrorAction SilentlyContinue) {
                        Remove-Item -LiteralPath $TemplatePath -Force -ErrorAction SilentlyContinue
                        $RemovedTemplateCount++
                    }
                }
                Write-Host " Cleaned : $RemovedTemplateCount downloaded template(s) from $ClassificationTemplates (pre-existing files preserved)" -ForegroundColor DarkGray
            }
        }

        # Remove Classification root if we created it and it is now empty
        if (-not $KeepClassificationFiles -and -not [string]::IsNullOrEmpty($ClassificationRoot) -and ($CreatedPaths -contains $ClassificationRoot) -and (Test-Path -LiteralPath $ClassificationRoot -ErrorAction SilentlyContinue)) {
            $Remaining = Get-ChildItem -LiteralPath $ClassificationRoot -Recurse -ErrorAction SilentlyContinue
            if (-not $Remaining) {
                Remove-Item -LiteralPath $ClassificationRoot -Recurse -Force -ErrorAction SilentlyContinue
                Write-Host " Removed : empty root $ClassificationRoot" -ForegroundColor DarkGray
            }
        }

        # Clear EntraOps cache and disconnect from Azure / Microsoft Graph
        try {
            Disconnect-EntraOps -ClearEntraOpsCache All -ErrorAction SilentlyContinue
            Write-Host " Status : Cache cleared and session disconnected." -ForegroundColor Green
        } catch {
            Write-Warning "Disconnect-EntraOps encountered an error during teardown: $($_.Exception.Message)"
        }
        Write-Host ""
    }
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDJyTR7aI/26TIW
# oGx6HLL56Jx8RevEycOgO90ZaHEpS6CCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# 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
# ARUwLwYJKoZIhvcNAQkEMSIEIGqVwqzjcdoQgd+9FPwtzhWh7uFyaikASmxoyFo6
# F9bZMA0GCSqGSIb3DQEBAQUABIIBgJhNP8VWfl7ga8o5m+h0vR+FQfMz16TEyt8H
# TgyD8L4hobG478tPJERHv+tEqM1Z+1mPzVxC/vzgf0LLlOAKVcVPZp2IDl7dsDMf
# FFpPm0sByOs5nIUTQNkI/LPMCbRMDMUoNut/5+qefZ44A3WLtXjlhZDpj71SjLwp
# QK9IeQMRDwYq0ph7ezi61WWmh+4ynqNVCXH/P7eov/NgRAwnph2LX5hy6oCc2BOT
# xtW17orfDD/j6HoZ93ZYxBtFCvoIAEe9BdFXal258FGFazjk5nD3rmZuXelRjHa5
# kZwjLxpqBasVYVroqMNiAXgPVz6K9cMEJFLwnabIpKT1wauRmqoJcEMkC1QqPRqL
# HIUdiGfsPzDJs+IypgQ+P7t3OyIx9S3ypqetMLqdwlw5ftZcR8BwQnavqfQLK1z4
# f5ITdbm6p0h64/84656UyUAlzQoJ/vY/zE/UU2kD00RNI/csrPcVRN57lSG3ZFR0
# VhWSlo49nclh1yE2pHVL1lmR4nvRjaGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMjcwNTQwMjZaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDCx7cMbCembciGiBSNXDV98HRb+NKRbReAI
# FaBox0vxAW/JujmMop0lnLMTcYjZjn0wgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAiMrwq8sbSpR7WDam0piG7HTCOCFHCNsiqwcWHuyS0csSLZxt
# CBD4Zw5NApa9DzjAD0lh9UUHh79hQg2IRm3UVPaQVQkPEcfDBsW4I229nHj0/SMK
# NUlFPJSjlPvTsKoGuiDPDknC/KtIUcK1zqw46NWd9UoDqBAPXe5MpTSb5cr9YfNs
# IPu4dSCkePlS3cruRZ25KaPIqOhA54tFmamhBjdoq8454F5h2A3gjls/bZJ6Hoi2
# fP/Lx3wLpS5FTG+UTf15G+YcOFkPszy45Wf9xfCPmw9zwStSxoeCW/5i0jLgVqTH
# F80Q5iHxNa0Dqjk7SaTYgbsiezb9d10vStbE1auhO72AsfDseVcCpKV0vSmik0nm
# E4URwF+A8ih3lJfdsmS0kkEWmWABL2DJiTd9HuZkjTWTBH2wh9TvX423nSlS1N3C
# swqeFx35o5S86us4twUIJEiy1R5zuuQnysjpFviFQvrAcWlVbFaRFv6M3Va7NPag
# MGvr5miNTiySFP2tDWbfkSltAxMPJ++lwqLMnv0CXHMsMTCHmYYGsVN/iUPWs5zM
# J2xGdmp1J6r2YQsEHF0N39J+GTgrmLFusqUHdhNoLIszWYW8q8FHM7ObxfJOiH1g
# 6W/UyjxzTlEFjApvPOcjG80YmugVtA2tKWY8brClP/3PRMY/mar7UB/S9Uc=
# SIG # End signature block