Public/PrivilegedAccess/Export-EntraOpsPrivilegedEAMBloodHound.ps1

<#
.SYNOPSIS
    Export EntraOps Privileged EAM data as a BloodHound OpenGraph JSON file.

.DESCRIPTION
    Reads the existing EAM JSON export files and converts them into a BloodHound
    OpenGraph-compatible JSON payload (graph.nodes + graph.edges) that can be
    uploaded directly to BloodHound CE or Enterprise via the standard UI upload
    or the BloodHound API. The payload is intended to enrich an existing
    AzureHound-ingested tenant graph.

    Node kinds emitted:
        AzureHound-native principals:
            AZUser, AZGroup, AZServicePrincipal, AZDevice
        AzureHound-native Entra ID role nodes:
            AZRole
        EntraOps role nodes:
            EO_DefenderRole, EO_IntuneRole, EO_IdGovRole, EO_AppRole
        EntraOps role-assignment nodes:
            EO_EntraRoleAssignment, EO_DefenderRoleAssignment, EO_IntuneRoleAssignment,
            EO_IdGovRoleAssignment, EO_AppRoleAssignment
        EntraOps other nodes:
            EO_AdministrativeUnit, EO_Base

    Edge kinds emitted:
        EO_HasEntraRole - principal holds an active Entra ID directory role
        EO_HasDefenderRole - principal holds an active Defender RBAC role
        EO_HasIntuneRole - principal holds an active Intune / Device Management role
        EO_HasIdGovRole - principal holds an active Identity Governance role
        EO_HasAppRole - principal holds an active Resource App API permission role
        EO_EligibleForEntraRole - principal is PIM-eligible for an Entra ID directory role
        EO_EligibleForDefenderRole - principal is PIM-eligible for a Defender RBAC role
        EO_EligibleForIntuneRole - principal is PIM-eligible for an Intune role
        EO_EligibleForIdGovRole - principal is PIM-eligible for an Identity Governance role
        EO_EligibleForAppRole - principal is PIM-eligible for a Resource App role
        EO_Has*RoleAssignment - principal has a concrete EntraOps role-assignment node
        EO_*RoleAssigned - role definition is linked to its role-assignment node
        EO_ClassifiedViaObject - principal or role assignment was tier-classified because of a tagged object
        EO_ScopedTo - role assignment is scoped to an administrative unit or tenant
        EO_AssignedToAdministrativeUnit - principal is a member of an administrative unit
        EO_HasWorkAccount - privileged account is linked to a standard work account
        EO_UsesPAW - privileged account is assigned a PAW/SAW device
        EO_PAWFor - PAW/SAW device is assigned to a privileged account
        EO_OwnsDevice - principal owns / has registered a device
        EO_DeviceOwner - registered device is owned by a principal
        EO_OwnerOf - principal owns another privileged object (group, application/service principal, ...)
        EO_OwnedBy - privileged object is owned by another principal (inverse of EO_OwnerOf)
        EO_IsSponsoredBy - guest or external identity is sponsored by another principal
        EO_HasIdentityParent - identity was derived from / linked to a parent identity
        EO_IntuneRolePermission - Intune role assignment/principal has matched actions scoped to a device

    Entra ID role definitions use the AzureHound-native AZRole kind so EntraOps
    enriches the canonical BloodHound node instead of trying to change its kind.
    EntraOps-owned EO_HasEntraRole, EO_EligibleForEntraRole,
    EO_EntraRoleAssignment, EO_HasEntraRoleAssignment, and EO_EntraRoleAssigned
    preserve the additional assignment and classification context.

    Top-level EntraOps tier/name properties are stored on nodes;
    per-assignment classification details travel as edge properties.

.PARAMETER ImportPath
    Root folder that holds the per-RBAC-system export sub-folders produced by
    Save-EntraOpsPrivilegedEAMJson. Defaults to $DefaultFolderClassifiedEam.

.PARAMETER RbacSystems
    Which RBAC systems to include. Defaults to all five supported systems.

.PARAMETER OutputPath
    Where to write the resulting JSON file. Defaults to
    <ImportPath>/BloodHound/EntraOps_OpenGraph.json.

.PARAMETER IncludeClassifiedViaObjectEdges
    When $true (default), emit EO_ClassifiedViaObject edges from principals to the
    objects referenced in TaggedByObjectIds. Set to $false to reduce graph size
    when you only need the role-assignment edges.

.PARAMETER IncludeAdministrativeUnitEdges
    When $true (default), emit EO_ScopedTo edges for administrative unit and
    tenant-wide role assignment scopes, and EO_AdministrativeUnit nodes for AU scopes.
    Set to $false to omit scope edges and AU scope nodes.

.PARAMETER IncludeAdministrativeUnitMembershipEdges
    When $true (default), emit EO_AssignedToAdministrativeUnit edges from each principal to
    the administrative units it is a direct member of (from AssignedAdministrativeUnits).
    Set to $false to omit these edges.

.PARAMETER IncludeWorkAccountEdges
    When $true (default), emit EO_HasWorkAccount edges from a privileged account to its associated
    standard work account (AssociatedWorkAccount) and EO_UsesPAW edges to assigned PAW/SAW
    devices (AssociatedPawDevice). Both are populated from custom security attributes.
    Set to $false to omit these edges.

.PARAMETER IncludeDeviceOwnershipEdges
    When $true (default), emit EO_OwnsDevice edges from a principal to each device it has registered
    or owns (OwnedDevices), plus inverse EO_DeviceOwner edges from the device to the principal.
    Set to $false to omit these edges.

.PARAMETER IncludeObjectOwnershipEdges
    When $true (default), emit EO_OwnerOf edges from a principal to each privileged object it owns
    (OwnedObjects - typically groups, applications/service principals it manages) and from each
    owner listed on a privileged object (Owners - e.g. a role-assignable group's owners), plus
    inverse EO_OwnedBy edges. Set to $false to omit these edges.

.PARAMETER IncludeSponsorEdges
    When $true (default), emit EO_IsSponsoredBy edges from a guest or external identity to each of
    its sponsors (Sponsors). Set to $false to omit these edges.

.PARAMETER IncludeIdentityParentEdges
    When $true (default), emit EO_HasIdentityParent edges from an identity to its parent identity
    (IdentityParent), e.g. an agent service principal linked back to the originating application.
    Set to $false to omit these edges.

.PARAMETER IncludeDeviceActionEdges
    When $true (default), emit EO_IntuneRolePermission edges for DeviceManagement assignments
    where classification matched Intune actions to scoped devices. Edges are emitted from both the
    role-assignment node and the principal to the target device. Set to $false to omit these edges.

.EXAMPLE
    Export-EntraOpsPrivilegedEAMBloodHound
    # Writes EntraOps_OpenGraph.json to the default BloodHound sub-folder.

.EXAMPLE
    Export-EntraOpsPrivilegedEAMBloodHound -RbacSystems @("EntraID","ResourceApps") -OutputPath "C:\Temp\entraops.json"

.EXAMPLE
    Export-EntraOpsPrivilegedEAMBloodHound -IncludeClassifiedViaObjectEdges $false
#>

function Export-EntraOpsPrivilegedEAMBloodHound {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [System.String]$TenantId
        ,
        [Parameter(Mandatory = $false)]
        [System.String]$ImportPath = $DefaultFolderClassifiedEam
        ,
        [Parameter(Mandatory = $false)]
        [ValidateSet("EntraID", "IdentityGovernance", "DeviceManagement", "ResourceApps", "Defender")]
        [Array]$RbacSystems = @("EntraID", "IdentityGovernance", "DeviceManagement", "ResourceApps", "Defender")
        ,
        [Parameter(Mandatory = $false)]
        [System.String]$OutputPath = ""
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeClassifiedViaObjectEdges = $true
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeAdministrativeUnitEdges = $true
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeAdministrativeUnitMembershipEdges = $true
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeWorkAccountEdges = $true
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeDeviceOwnershipEdges = $true
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeObjectOwnershipEdges = $true
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeSponsorEdges = $true
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeIdentityParentEdges = $true
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeDeviceActionEdges = $true
    )

    # ── Default output path ────────────────────────────────────────────────────
    if ([string]::IsNullOrEmpty($OutputPath)) {
        $OutputDir = Join-Path $ImportPath "BloodHound"
        if (-not (Test-Path $OutputDir)) {
            New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
        }
        $OutputPath = Join-Path $OutputDir "EntraOps_OpenGraph.json"
    }

    # ── Lookup tables ──────────────────────────────────────────────────────────
    $HasRoleEdgeKindMap = @{
        'EntraID'            = 'EO_HasEntraRole'
        'Defender'           = 'EO_HasDefenderRole'
        'DeviceManagement'   = 'EO_HasIntuneRole'
        'IdentityGovernance' = 'EO_HasIdGovRole'
        'ResourceApps'       = 'EO_HasAppRole'
    }

    $HasRoleAssignmentEdgeKindMap = @{
        'EntraID'            = 'EO_HasEntraRoleAssignment'
        'Defender'           = 'EO_HasDefenderRoleAssignment'
        'DeviceManagement'   = 'EO_HasIntuneRoleAssignment'
        'IdentityGovernance' = 'EO_HasIdGovRoleAssignment'
        'ResourceApps'       = 'EO_HasAppRoleAssignment'
    }

    $RoleAssignedEdgeKindMap = @{
        'EntraID'            = 'EO_EntraRoleAssigned'
        'Defender'           = 'EO_DefenderRoleAssigned'
        'DeviceManagement'   = 'EO_IntuneRoleAssigned'
        'IdentityGovernance' = 'EO_IdGovRoleAssigned'
        'ResourceApps'       = 'EO_AppRoleAssigned'
    }

    # Eligible edge kinds (PIM-eligible assignments)
    $EligibleEdgeKindMap = @{
        'EntraID'            = 'EO_EligibleForEntraRole'
        'Defender'           = 'EO_EligibleForDefenderRole'
        'DeviceManagement'   = 'EO_EligibleForIntuneRole'
        'IdentityGovernance' = 'EO_EligibleForIdGovRole'
        'ResourceApps'       = 'EO_EligibleForAppRole'
    }

    $RoleNodeKindMap = @{
        'EntraID'            = @('AZRole')
        'Defender'           = @('EO_DefenderRole')
        'DeviceManagement'   = @('EO_IntuneRole')
        'IdentityGovernance' = @('EO_IdGovRole')
        'ResourceApps'       = @('EO_AppRole')
    }

    $RoleAssignmentNodeKindMap = @{
        'EntraID'            = @('EO_EntraRoleAssignment')
        'Defender'           = @('EO_DefenderRoleAssignment')
        'DeviceManagement'   = @('EO_IntuneRoleAssignment')
        'IdentityGovernance' = @('EO_IdGovRoleAssignment')
        'ResourceApps'       = @('EO_AppRoleAssignment')
    }

    # Node kinds
    $NodeKind = @{
        Base               = 'EO_Base'
        AdministrativeUnit = 'EO_AdministrativeUnit'
    }

    # Edge kinds
    $EdgeKind = @{
        MemberOf             = 'AZMemberOf'
        ScopedTo             = 'EO_ScopedTo'
        AssignedToAU         = 'EO_AssignedToAdministrativeUnit'
        ClassifiedViaObject  = 'EO_ClassifiedViaObject'
        HasWorkAccount       = 'EO_HasWorkAccount'
        UsesPAW              = 'EO_UsesPAW'
        PAWFor               = 'EO_PAWFor'
        OwnsDevice           = 'EO_OwnsDevice'
        DeviceOwner          = 'EO_DeviceOwner'
        OwnerOf              = 'EO_OwnerOf'
        OwnedBy              = 'EO_OwnedBy'
        IsSponsoredBy        = 'EO_IsSponsoredBy'
        HasIdentityParent    = 'EO_HasIdentityParent'
        IntuneRolePermission = 'EO_IntuneRolePermission'
    }

    # ObjectType → primary node kind
    $PrincipalKindMap = @{
        'user'             = @('AZUser')
        'group'            = @('AZGroup')
        'serviceprincipal' = @('AZServicePrincipal')
        'device'           = @('AZDevice')
    }

    # ── Graph data structures ──────────────────────────────────────────────────
    # $NodesIndex : id (string) → node hashtable (deduplicated)
    # $EdgesIndex : "startId|endId|kind" → $true (deduplicated)
    # $EdgesList : ordered list of edge objects
    $NodesIndex = @{}
    $EdgesIndex = @{}
    $EdgesList = [System.Collections.Generic.List[object]]::new()

    # ── Precomputed edge kind unions for Cypher queries in node properties ──────
    $AllActiveRoleEdgeKinds = ($HasRoleEdgeKindMap.Values) -join '|'
    $AllEligibleRoleEdgeKinds = ($EligibleEdgeKindMap.Values) -join '|'
    $AllRoleAssignmentEdges = ($HasRoleAssignmentEdgeKindMap.Values) -join '|'
    $AllRoleAssignedEdges = ($RoleAssignedEdgeKindMap.Values) -join '|'

    # ── Helper: upsert a node ─────────────────────────────────────────────────
    # Keeps the first-seen properties; later calls can update only if the existing
    # node lacks admintierlevel (i.e. it was a stub created for a referenced object).
    function Upsert-Node {
        param(
            [string]$Id,
            [string[]]$Kinds,
            [hashtable]$Properties
        )
        $Id = $Id.ToUpper()
        if (-not $NodesIndex.ContainsKey($Id)) {
            $NodesIndex[$Id] = @{
                id         = $Id
                kinds      = $Kinds
                properties = $Properties
            }
        } else {
            $existing = $NodesIndex[$Id]
            # Upgrade a stub node when:
            # - existing is EO_Base only and new data has a more specific kind, OR
            # - existing lacks admintierlevel and new data provides it
            $existingIsBaseOnly = ($existing.kinds.Count -eq 1 -and $existing.kinds[0] -eq $NodeKind.Base)
            $newIsMoreSpecific = ($Kinds.Count -gt 0 -and $Kinds[0] -ne $NodeKind.Base)
            $upgradeByTier = ($null -eq $existing.properties['admintierlevel']) -and
            ($null -ne $Properties['admintierlevel'])
            if (($existingIsBaseOnly -and $newIsMoreSpecific) -or $upgradeByTier) {
                $NodesIndex[$Id] = @{
                    id         = $Id
                    kinds      = $Kinds
                    properties = $Properties
                }
            }
        }
    }

    function Ensure-AZDeviceNode {
        param([string]$Id)
        if ([string]::IsNullOrEmpty($Id)) { return }

        Upsert-Node -Id $Id -Kinds @('AZDevice') -Properties @{}
    }

    # ── Helper: append a deduplicated edge ─────────────────────────────────────
    function Add-Edge {
        param(
            [string]$StartId,
            [string]$EndId,
            [string]$Kind,
            [hashtable]$Properties = $null
        )
        if ([string]::IsNullOrEmpty($StartId) -or [string]::IsNullOrEmpty($EndId)) { return }
        $StartId = $StartId.ToUpper()
        $EndId = $EndId.ToUpper()
        $dedupeKey = "${StartId}|${EndId}|${Kind}"
        if (-not $EdgesIndex.ContainsKey($dedupeKey)) {
            $EdgesIndex[$dedupeKey] = $true
            $edge = @{
                kind  = $Kind
                start = @{ match_by = "id"; value = $StartId }
                end   = @{ match_by = "id"; value = $EndId }
            }
            if ($null -ne $Properties -and $Properties.Count -gt 0) {
                $edge['properties'] = $Properties
            }
            $EdgesList.Add($edge) | Out-Null
        }
    }

    function Get-EndpointDedupeKey {
        # Build a deterministic key from an endpoint dictionary (sorted by key name)
        # to avoid duplicate edges caused by non-deterministic hashtable enumeration order in ConvertTo-Json
        param([System.Collections.IDictionary]$Endpoint)
        $pairs = foreach ($key in ($Endpoint.Keys | Sort-Object)) {
            $value = $Endpoint[$key]
            if ($value -is [System.Collections.IDictionary] -or ($value -is [System.Collections.IEnumerable] -and $value -isnot [string])) {
                $value = $value | ConvertTo-Json -Compress -Depth 5
            }
            "$key=$value"
        }
        return ($pairs -join ';')
    }

    function Add-EdgeByEndpoint {
        param(
            [System.Collections.IDictionary]$StartEndpoint,
            [System.Collections.IDictionary]$EndEndpoint,
            [string]$Kind,
            [hashtable]$Properties = $null
        )
        if ($null -eq $StartEndpoint -or $null -eq $EndEndpoint) { return }

        $startKey = Get-EndpointDedupeKey -Endpoint $StartEndpoint
        $endKey = Get-EndpointDedupeKey -Endpoint $EndEndpoint
        $dedupeKey = "${startKey}|${endKey}|${Kind}"
        if (-not $EdgesIndex.ContainsKey($dedupeKey)) {
            $EdgesIndex[$dedupeKey] = $true
            $edge = @{
                kind  = $Kind
                start = $StartEndpoint
                end   = $EndEndpoint
            }
            if ($null -ne $Properties -and $Properties.Count -gt 0) {
                $edge['properties'] = $Properties
            }
            $EdgesList.Add($edge) | Out-Null
        }
    }

    function ConvertTo-PimMemberOfEdgeProperties {
        param(
            [object]$RoleAssignment,
            [string]$RbacSystem
        )

        return @{
            rbacsystem              = [string]$RbacSystem
            roleassignmenttype      = [string]($RoleAssignment.RoleAssignmentType ?? '')
            roleassignmentsubtype   = [string]($RoleAssignment.RoleAssignmentSubType ?? '')
            pimassignmenttype       = [string]($RoleAssignment.PIMAssignmentType ?? '')
            roleassignmentid        = [string]($RoleAssignment.RoleAssignmentId ?? '')
            roleassignmentinstanceid = [string]($RoleAssignment.RoleAssignmentInstanceId ?? '')
            roleassignmentscopeid   = [string]($RoleAssignment.RoleAssignmentScopeId ?? '')
            roleassignmentscopename = [string]($RoleAssignment.RoleAssignmentScopeName ?? '')
        }
    }

    function Get-PrimitiveStringArray {
        param([object]$Value)

        if ($null -eq $Value) { return @() }

        return @(
            $Value |
            Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) } |
            ForEach-Object { [string]$_ }
        )
    }

    function Add-PimEligibleMemberOfEdges {
        param(
            [string]$PrincipalId,
            [object]$RoleAssignment,
            [string]$RbacSystem
        )

        if ([string]::IsNullOrEmpty($PrincipalId) -or $null -eq $RoleAssignment) { return }
        if ([string]($RoleAssignment.RoleAssignmentType ?? '') -ne 'Transitive') { return }

        $roleAssignmentSubType = [string]($RoleAssignment.RoleAssignmentSubType ?? '')
        $edgeProps = ConvertTo-PimMemberOfEdgeProperties -RoleAssignment $RoleAssignment -RbacSystem $RbacSystem

        if ($roleAssignmentSubType -eq 'Eligible member') {
            $transitiveByObjectId = [string]($RoleAssignment.TransitiveByObjectId ?? '')
            if (-not [string]::IsNullOrWhiteSpace($transitiveByObjectId)) {
                Add-Edge -StartId $PrincipalId -EndId $transitiveByObjectId -Kind $EdgeKind.MemberOf -Properties $edgeProps
            }
            return
        }

        if ($roleAssignmentSubType -notin @('Nested Eligible member', 'Nested Eligible group member')) {
            return
        }

        $nestingIds = Get-PrimitiveStringArray -Value $RoleAssignment.TransitiveByNestingObjectIds
        if ($nestingIds.Count -lt 2) { return }

        for ($ni = 1; $ni -lt $nestingIds.Count; $ni++) {
            Add-Edge -StartId $nestingIds[$ni] -EndId $nestingIds[$ni - 1] -Kind $EdgeKind.MemberOf -Properties $edgeProps
        }
    }

    # ── Helper: pick the best (lowest tier) classification item ───────────────
    function Get-BestClassification {
        param([array]$Classifications)
        if ($null -eq $Classifications -or $Classifications.Count -eq 0) { return $null }
        return $Classifications |
        Where-Object { $null -ne $_.AdminTierLevel } |
        Sort-Object AdminTierLevel |
        Select-Object -First 1
    }

    # ── Helper: build flat edge properties from a classification item ──────────
    # OpenGraph requires flat primitives only – no nested objects.
    function ConvertTo-EdgeProperties {
        param(
            [object]$Classification,
            [object]$RoleAssignment,
            [string]$RbacSystem
        )
        $props = @{}

        if ($null -ne $Classification) {
            $props['admintierlevel'] = [string]($Classification.AdminTierLevel ?? '')
            $props['admintierlevelname'] = [string]($Classification.AdminTierLevelName ?? '')
            $props['service'] = [string]($Classification.Service ?? '')
            $props['taggedby'] = [string]($Classification.TaggedBy ?? '')
            $props['taggedbyrolesystem'] = [string]($Classification.TaggedByRoleSystem ?? '')
        }

        if ($null -ne $RoleAssignment) {
            $props['roleassignmenttype'] = [string]($RoleAssignment.RoleAssignmentType ?? '')
            $props['roleassignmentsubtype'] = [string]($RoleAssignment.RoleAssignmentSubType ?? '')
            $props['pimassignmenttype'] = [string]($RoleAssignment.PIMAssignmentType ?? '')
            $props['pimmanagedrole'] = [bool]  ($RoleAssignment.PIMManagedRole -eq $true)
            $props['roleassignmentscopeid'] = [string]($RoleAssignment.RoleAssignmentScopeId ?? '')
            $props['roleassignmentscopename'] = [string]($RoleAssignment.RoleAssignmentScopeName ?? '')
            $props['rbacsystem'] = [string]$RbacSystem
        }

        return $props
    }

    # Keep EO_IntuneRolePermission edges focused on direct device-compromise
    # evidence. Source EAM MatchedActions remains unfiltered.
    function Get-PrunedIntunePermissionActions {
        param([object[]]$Actions)

        $directCompromiseActions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($directCompromiseAction in @(
                'Microsoft.Intune/DeviceConfigurations/Assign',
                'Microsoft.Intune/DeviceConfigurations/Create',
                'Microsoft.Intune/DeviceConfigurations/Update',
                'Microsoft.Intune/MobileApps/Assign',
                'Microsoft.Intune/MobileApps/Create',
                'Microsoft.Intune/MobileApps/Relate',
                'Microsoft.Intune/MobileApps/Update',
                'Microsoft.Intune/RemoteTasks/OnDemandProactiveRemediation',
                'Microsoft.Intune/RemoteTasks/RequestRemoteAssistance'
            )) {
            [void] $directCompromiseActions.Add($directCompromiseAction)
        }

        $prunedActions = [System.Collections.Generic.List[string]]::new()
        $seenActions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($action in @($Actions)) {
            if ($null -eq $action) { continue }

            $actionStr = ([string]$action).Trim()
            if ([string]::IsNullOrEmpty($actionStr)) { continue }

            if (-not $directCompromiseActions.Contains($actionStr)) { continue }

            if ($seenActions.Add($actionStr)) {
                $prunedActions.Add($actionStr)
            }
        }

        return @($prunedActions)
    }

    # ── Main loop ─────────────────────────────────────────────────────────────

    foreach ($RbacSystem in $RbacSystems) {
        $JsonFile = Join-Path $ImportPath "$RbacSystem/$RbacSystem.json"
        if (-not (Test-Path $JsonFile)) {
            Write-Warning "No export file found for $RbacSystem at $JsonFile – skipping."
            continue
        }

        Write-Host "Processing $RbacSystem..." -ForegroundColor Cyan
        try {
            $Privileges = Get-Content -Path $JsonFile -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 10
        } catch {
            Write-Warning "Failed to read $($JsonFile): $_"
            continue
        }

        $RoleEdgeKind = $HasRoleEdgeKindMap[$RbacSystem]
        $RoleEligibleEdgeKind = $EligibleEdgeKindMap[$RbacSystem]
        $HasRoleAssignmentEdgeKind = $HasRoleAssignmentEdgeKindMap[$RbacSystem]
        $RoleAssignedEdgeKind = $RoleAssignedEdgeKindMap[$RbacSystem]
        $RoleNodeKinds = $RoleNodeKindMap[$RbacSystem]
        $RoleAssignmentNodeKinds = $RoleAssignmentNodeKindMap[$RbacSystem]

        foreach ($Privilege in $Privileges) {
            # ── 1. Principal node ──────────────────────────────────────────────
            $principalId = [string]$Privilege.ObjectId
            if ([string]::IsNullOrEmpty($principalId)) { continue }

            # Top-level Classification[] is an array of objects – flatten to primitive arrays
            $tierLevels = @(
                $Privilege.Classification |
                Where-Object { $null -ne $_.AdminTierLevel } |
                Select-Object -ExpandProperty AdminTierLevel -Unique |
                ForEach-Object { [string]$_ }
            )
            $tierNames = @(
                $Privilege.Classification |
                Where-Object { $null -ne $_.AdminTierLevelName } |
                Select-Object -ExpandProperty AdminTierLevelName -Unique |
                ForEach-Object { [string]$_ }
            )
            $services = @(
                $Privilege.Classification |
                Where-Object { $null -ne $_.Service } |
                Select-Object -ExpandProperty Service -Unique |
                ForEach-Object { [string]$_ }
            )

            $principalProps = @{
                entraopsadmintierlevel        = [string]($Privilege.ObjectAdminTierLevel ?? '')
                entraopsadmintierlevelname    = [string]($Privilege.ObjectAdminTierLevelName ?? '')
                restrictedmanagementbyrag     = [bool]  ($Privilege.RestrictedManagementByRAG -eq $true)
                restrictedmanagementbyaadrole = [bool]  ($Privilege.RestrictedManagementByAadRole -eq $true)
                restrictedmanagementbyrmau    = [bool]  ($Privilege.RestrictedManagementByRMAU -eq $true)
            }

            # For service principals ObjectSignInName holds the appId – surface it explicitly
            # so BloodHound can cross-match against AZApp / AZServicePrincipal nodes from AzureHound
            if (($Privilege.ObjectType ?? '').ToLower() -eq 'serviceprincipal') {
                $spAppId = [string]($Privilege.ObjectUserPrincipalName ?? '')
                if (-not [string]::IsNullOrEmpty($spAppId)) {
                    $principalProps['appid'] = $spAppId
                }
            }
            
            if ($tierLevels.Count -gt 0) { $principalProps['classification_tierlevels'] = $tierLevels }
            if ($tierNames.Count -gt 0) { $principalProps['classification_tiernames'] = $tierNames }
            if ($services.Count -gt 0) { $principalProps['classification_services'] = $services }
            
            $primaryKind = $PrincipalKindMap[($Privilege.ObjectType ?? '').ToLower()] ?? $NodeKind.Base
            Upsert-Node -Id $principalId -Kinds $primaryKind -Properties $principalProps

            # ── 1a. AssignedToAdministrativeUnit edges (AU membership of the object) ──
            if ($IncludeAdministrativeUnitMembershipEdges) {
                $assignedAUs = @($Privilege.AssignedAdministrativeUnits | Where-Object { $null -ne $_ })
                foreach ($AU in $assignedAUs) {
                    $auId = [string]($AU.id ?? '')
                    if ([string]::IsNullOrEmpty($auId)) { continue }
                    $auNodeId = "au-$auId"
                    $auName = [string]($AU.displayName ?? $auId)
                    
                    Upsert-Node -Id $auNodeId -Kinds $NodeKind.AdministrativeUnit -Properties @{
                        name        = $auName
                        displayname = $auName
                    }

                    Add-Edge -StartId $principalId -EndId $auNodeId -Kind $EdgeKind.AssignedToAU

                }
            }

            # ── 1b. Work account & PAW device edges ───────────────────────────
            if ($IncludeWorkAccountEdges) {
                # HasWorkAccount: admin account → associated standard work account
                $workAccountIds = @($Privilege.AssociatedWorkAccount | Where-Object { $null -ne $_ -and $_ -ne '' } | ForEach-Object { [string]$_ })
                foreach ($waId in $workAccountIds) {
                    Add-Edge -StartId $principalId -EndId $waId -Kind $EdgeKind.HasWorkAccount -Properties @{
                        rbacsystem = [string]$RbacSystem
                    }

                }

                # UsesPAW: admin account -> assigned PAW/SAW device; PAWFor is the inverse.
                $pawDeviceIds = @($Privilege.AssociatedPawDevice | Where-Object { $null -ne $_ -and $_ -ne '' } | ForEach-Object { [string]$_ })
                foreach ($pawId in $pawDeviceIds) {
                    $pawEdgeProps = @{
                        rbacsystem = [string]$RbacSystem
                    }

                    $pawDeviceEndpoint = [ordered]@{
                        match_by          = 'property'
                        kind              = 'AZDevice'
                        property_matchers = @(
                            [ordered]@{
                                key      = 'deviceid'
                                operator = 'equals'
                                value    = ([string]$pawId).ToLowerInvariant()
                            }
                        )
                    }
                    $principalEndpoint = [ordered]@{
                        match_by          = 'property'
                        kind              = @($primaryKind)[0]
                        property_matchers = @(
                            [ordered]@{
                                key      = 'objectid'
                                operator = 'equals'
                                value    = ([string]$principalId).ToUpperInvariant()
                            }
                        )
                    }

                    Add-EdgeByEndpoint -StartEndpoint $principalEndpoint -EndEndpoint $pawDeviceEndpoint -Kind $EdgeKind.UsesPAW -Properties $pawEdgeProps
                    Add-EdgeByEndpoint -StartEndpoint $pawDeviceEndpoint -EndEndpoint $principalEndpoint -Kind $EdgeKind.PAWFor -Properties $pawEdgeProps

                }
            }

            # ── 1c. Owned device edges ────────────────────────────────────────
            if ($IncludeDeviceOwnershipEdges) {
                $ownedDeviceIds = @($Privilege.OwnedDevices | Where-Object { $null -ne $_ -and $_ -ne '' } | ForEach-Object { [string]$_ })

                foreach ($devId in $ownedDeviceIds) {
                    $ownedDeviceEdgeProps = @{
                        rbacsystem = [string]$RbacSystem
                    }
                    Ensure-AZDeviceNode -Id $devId
                    Add-Edge -StartId $principalId -EndId $devId -Kind $EdgeKind.OwnsDevice -Properties $ownedDeviceEdgeProps
                    Add-Edge -StartId $devId -EndId $principalId -Kind $EdgeKind.DeviceOwner -Properties $ownedDeviceEdgeProps

                }
            }

            # ── 1c2. Owned object edges (groups/apps/service principals - not devices) ──
            # OwnedObjects lists what this principal owns (e.g. a user/service principal
            # that owns groups or app registrations); Owners lists who owns this object
            # (e.g. a role-assignable group's owners). Both are emitted as EO_OwnerOf /
            # EO_OwnedBy so either direction of collection produces the same edge - Add-Edge
            # dedupes by (start, end, kind), so overlap between the two is harmless. Targets
            # that aren't privileged objects in this export are resolved to stub nodes by
            # New-EntraOpsAccessPathMapData (same pattern as other cross-object edges here).
            if ($IncludeObjectOwnershipEdges) {
                $ownedObjectIds = @($Privilege.OwnedObjects | Where-Object { $null -ne $_ -and $_ -ne '' } | ForEach-Object { [string]$_ })
                foreach ($ownedId in $ownedObjectIds) {
                    $ownershipEdgeProps = @{
                        rbacsystem = [string]$RbacSystem
                    }
                    Add-Edge -StartId $principalId -EndId $ownedId -Kind $EdgeKind.OwnerOf -Properties $ownershipEdgeProps
                    Add-Edge -StartId $ownedId -EndId $principalId -Kind $EdgeKind.OwnedBy -Properties $ownershipEdgeProps

                }

                $ownerIds = @($Privilege.Owners | Where-Object { $null -ne $_ -and $_ -ne '' } | ForEach-Object { [string]$_ })
                foreach ($ownerId in $ownerIds) {
                    $ownershipEdgeProps = @{
                        rbacsystem = [string]$RbacSystem
                    }
                    Add-Edge -StartId $ownerId -EndId $principalId -Kind $EdgeKind.OwnerOf -Properties $ownershipEdgeProps
                    Add-Edge -StartId $principalId -EndId $ownerId -Kind $EdgeKind.OwnedBy -Properties $ownershipEdgeProps

                }
            }

            # ── 1d. Sponsor edges ─────────────────────────────────────────────
            if ($IncludeSponsorEdges) {
                $sponsorIds = @($Privilege.Sponsors | Where-Object { $null -ne $_ -and $_ -ne '' } | ForEach-Object { [string]$_ })
                foreach ($sponsorId in $sponsorIds) {
                    Add-Edge -StartId $principalId -EndId $sponsorId -Kind $EdgeKind.IsSponsoredBy -Properties @{
                        rbacsystem = [string]$RbacSystem
                    }

                }
            }

            # ── 1e. Identity parent edges ─────────────────────────────────────
            if ($IncludeIdentityParentEdges) {
                $identityParentId = [string]($Privilege.IdentityParent ?? '')
                if (-not [string]::IsNullOrEmpty($identityParentId)) {
                    Add-Edge -StartId $principalId -EndId $identityParentId -Kind $EdgeKind.HasIdentityParent -Properties @{
                        rbacsystem = [string]$RbacSystem
                    }

                }
            }
            # ── 2. The principal's assignments, create Role and RoleAssignment nodes/edges ───────────────────────────────────────
            foreach ($RoleAssignment in $Privilege.RoleAssignments) {

                # Start Role Assignment node creation
                ######################################
                $roleAssignmentId = [string]($RoleAssignment.RoleAssignmentId ?? '')
                if ([string]::IsNullOrEmpty($roleAssignmentId)) { continue }
                $roleAssignmentInstanceId = [string]($RoleAssignment.RoleAssignmentInstanceId ?? '')
                if ([string]::IsNullOrEmpty($roleAssignmentInstanceId)) {
                    $roleAssignmentInstanceId = Get-EntraOpsRoleAssignmentInstanceId -RoleSystem $RbacSystem -RoleAssignment $RoleAssignment
                }

                #$roleAssignmentName = [string]($RoleAssignment.RoleDefinitionName ?? $roleAssignmentId)
                $roleAssignmentName = $roleAssignmentId

                $roleAssignmentProps = @{
                    name                    = $roleAssignmentName
                    displayname             = $roleAssignmentName
                    roleassignmentid         = $roleAssignmentId
                    roleassignmentinstanceid = $roleAssignmentInstanceId
                    rbacsystem              = [string]$RbacSystem
                    roleassignmentscopeid   = [string]($RoleAssignment.RoleAssignmentScopeId ?? '')
                    roleassignmentscopename = [string]($RoleAssignment.RoleAssignmentScopeName ?? '')
                    roleassignmenttype      = [string]($RoleAssignment.RoleAssignmentType ?? '')
                    roleassignmentsubtype   = [string]($RoleAssignment.RoleAssignmentSubType ?? '')
                }

                $matchedRoleAssignmentActions = @(
                    $RoleAssignment.Classification |
                    Where-Object { $null -ne $_.MatchedActions } |
                    ForEach-Object { $_.MatchedActions } |
                    Where-Object { -not [string]::IsNullOrEmpty($_) } |
                    Select-Object -Unique |
                    ForEach-Object { [string]$_ }
                )
                if ($matchedRoleAssignmentActions.Count -gt 0) {
                    $roleAssignmentProps['matchedactions'] = $matchedRoleAssignmentActions
                }

                # Role assignment node (stub – first occurrence wins full properties)
                Upsert-Node -Id $roleAssignmentInstanceId -Kinds $RoleAssignmentNodeKinds -Properties $roleAssignmentProps

                # Best classification for this specific assignment
                $bestClass = Get-BestClassification -Classifications $RoleAssignment.Classification
                $edgeProps = ConvertTo-EdgeProperties -Classification $bestClass -RoleAssignment $RoleAssignment -RbacSystem $RbacSystem

                Add-PimEligibleMemberOfEdges -PrincipalId $principalId -RoleAssignment $RoleAssignment -RbacSystem $RbacSystem

                # Direct or transitive assignment → role edge from principal
                Add-Edge -StartId $principalId -EndId $roleAssignmentInstanceId -Kind $HasRoleAssignmentEdgeKind -Properties $edgeProps
                

                # Start Role node creation
                ###############################
                $roleDefId = [string]($RoleAssignment.RoleDefinitionId ?? '')
                if ([string]::IsNullOrEmpty($roleDefId)) { continue }

                $roleNodeId = $roleDefId + "@" + $TenantId

                $roleDefName = [string]($RoleAssignment.RoleDefinitionName ?? $roleNodeId)

                # Role definition node (stub – first occurrence wins full properties)
                $roleNodeProps = @{
                    rbacsystem = [string]$RbacSystem
                }
                if ($RbacSystem -ne 'EntraID') {
                    $roleNodeProps['name'] = $roleDefName
                    $roleNodeProps['displayname'] = $roleDefName
                }
                Upsert-Node -Id $roleNodeId -Kinds $RoleNodeKinds -Properties $roleNodeProps

                # Best classification for this specific assignment
                $bestClass = Get-BestClassification -Classifications $RoleAssignment.Classification
                $edgeProps = ConvertTo-EdgeProperties -Classification $bestClass -RoleAssignment $RoleAssignment -RbacSystem $RbacSystem

                # Select active vs eligible edge kind based on PIM assignment type
                $effectiveEdgeKind = if ($RoleAssignment.PIMAssignmentType -eq 'Eligible') {
                    $RoleEligibleEdgeKind
                } else {
                    $RoleEdgeKind
                }

                # Direct or transitive assignment → role edge from principal
                Add-Edge -StartId $principalId -EndId $roleNodeId -Kind $effectiveEdgeKind -Properties $edgeProps

                # Link Role to RoleAssignment
                Add-Edge -StartId $roleNodeId -EndId $roleAssignmentInstanceId -Kind $RoleAssignedEdgeKind -Properties $edgeProps

                # ── Role assignment scope edge ────────────────────────────────
                if ($IncludeAdministrativeUnitEdges) {
                    $scopeId = [string]($RoleAssignment.RoleAssignmentScopeId ?? '')
                    $scopeId = $scopeId.Trim()
                    $scopeName = [string]($RoleAssignment.RoleAssignmentScopeName ?? $scopeId)
                    $roleAssignmentSubType = [string]($RoleAssignment.RoleAssignmentSubType ?? '')
                    $auScopeId = ''

                    if ($scopeId -match '^/?administrativeUnits?/([^/]+)$') {
                        $auScopeId = $Matches[1]
                    } elseif ($roleAssignmentSubType -match 'AdministrativeUnit' -and -not [string]::IsNullOrEmpty($scopeId)) {
                        $auScopeId = $scopeId
                    }

                    if (-not [string]::IsNullOrEmpty($auScopeId)) {
                        $auNodeId = "au-$auScopeId"
                        $auName = $scopeName

                        Upsert-Node -Id $auNodeId -Kinds $NodeKind.AdministrativeUnit -Properties @{
                            name        = $auName
                            displayname = $auName
                            rbacsystem  = [string]$RbacSystem
                        }

                        Add-Edge -StartId $roleAssignmentInstanceId -EndId $auNodeId -Kind $EdgeKind.ScopedTo -Properties @{
                            rbacsystem              = [string]$RbacSystem
                            roleassignmenttype      = [string]($RoleAssignment.RoleAssignmentType ?? '')
                            roleassignmentsubtype   = $roleAssignmentSubType
                            roleassignmentscopeid   = $scopeId
                            roleassignmentscopename = $auName
                            pimassignmenttype       = [string]($RoleAssignment.PIMAssignmentType ?? '')
                            roledefinitionid        = $roleNodeId
                            roledefinitionname      = $roleDefName
                        }

                    } elseif ([string]::IsNullOrWhiteSpace($scopeId) -or $scopeId -eq '/') {
                        Add-Edge -StartId $roleAssignmentInstanceId -EndId $TenantId -Kind $EdgeKind.ScopedTo -Properties @{
                            rbacsystem              = [string]$RbacSystem
                            roleassignmenttype      = [string]($RoleAssignment.RoleAssignmentType ?? '')
                            roleassignmentsubtype   = $roleAssignmentSubType
                            roleassignmentscopeid   = $scopeId
                            roleassignmentscopename = $scopeName
                            pimassignmenttype       = [string]($RoleAssignment.PIMAssignmentType ?? '')
                            roledefinitionid        = $roleNodeId
                            roledefinitionname      = $roleDefName
                        }

                    }
                }

                # ── 4. ClassifiedViaObject edges ──────────────────────────────
                if ($IncludeClassifiedViaObjectEdges) {
                    foreach ($ClassItem in $RoleAssignment.Classification) {
                        # Only emit when there are actual object IDs to point to
                        $taggedBy = [string]($ClassItem.TaggedBy ?? '')
                        if ($taggedBy -in @('ControlPlaneWithoutRoleActions', 'RoleDefinitionOverwrites', 'RoleActionOverwrites', '')) { continue }

                        $taggedIds = @($ClassItem.TaggedByObjectIds          | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ })
                        if ($taggedIds.Count -eq 0) { continue }
                        $taggedRoleSystem = [string]($ClassItem.TaggedByRoleSystem ?? '')

                        for ($ti = 0; $ti -lt $taggedIds.Count; $ti++) {
                            $taggedId = $taggedIds[$ti]
                            if ([string]::IsNullOrEmpty($taggedId)) { continue }

                            # For OAuthApplication the tagged id is an app role GUID – prefix to avoid
                            # collisions with role definition nodes that use the same GUID space
                            $resolvedTaggedId = if ($taggedBy -like '*OAuthApplication*') {
                                "app-role-$taggedId"
                            } else {
                                $taggedId
                            }

                            # Principal -[ClassifiedViaObject] -> (object)
                            Add-Edge -StartId $principalId -EndId $resolvedTaggedId -Kind $EdgeKind.ClassifiedViaObject -Properties @{
                                taggedby           = $taggedBy
                                taggedbyrolesystem = $taggedRoleSystem
                                admintierlevel     = [string]($ClassItem.AdminTierLevel ?? '')
                                admintierlevelname = [string]($ClassItem.AdminTierLevelName ?? '')
                                service            = [string]($ClassItem.Service ?? '')
                                rbacsystem         = [string]$RbacSystem
                            }

                            # RoleAssignment -[ClassifiedViaObject] -> (object)
                            Add-Edge -StartId $roleAssignmentInstanceId -EndId $resolvedTaggedId -Kind $EdgeKind.ClassifiedViaObject -Properties @{
                                taggedby           = $taggedBy
                                taggedbyrolesystem = $taggedRoleSystem
                                admintierlevel     = [string]($ClassItem.AdminTierLevel ?? '')
                                admintierlevelname = [string]($ClassItem.AdminTierLevelName ?? '')
                                service            = [string]($ClassItem.Service ?? '')
                                rbacsystem         = [string]$RbacSystem
                            }

                        }
                    }
                }

                # ── 5. IntuneRolePermission edges: RoleAssignment → Device ────────
                if ($IncludeDeviceActionEdges -and $RbacSystem -eq 'DeviceManagement') {
                    # Pre-aggregate: collect all actions and devices across classifications
                    # so we create one edge per roleAssignment→device with ALL actions combined
                    $deviceActionsMap = @{} # deviceId → @{ actions = [List]; tierLevel; tierName }
                    foreach ($ClassItem in $RoleAssignment.Classification) {
                        $scopedDevices = @($ClassItem.ScopedObjects | Where-Object { $null -ne $_ })
                        $matchedActions = Get-PrunedIntunePermissionActions -Actions @($ClassItem.MatchedActions | Where-Object { $null -ne $_ })
                        if ($scopedDevices.Count -eq 0 -or $matchedActions.Count -eq 0) { continue }

                        foreach ($scopedDevice in $scopedDevices) {
                            $deviceId = [string]($scopedDevice.id ?? '')
                            if ([string]::IsNullOrEmpty($deviceId)) { continue }

                            if (-not $deviceActionsMap.ContainsKey($deviceId)) {
                                $deviceActionsMap[$deviceId] = @{
                                    actions   = [System.Collections.Generic.List[string]]::new()
                                    tierLevel = [string]($ClassItem.AdminTierLevel ?? '')
                                    tierName  = [string]($ClassItem.AdminTierLevelName ?? '')
                                }
                            }
                            foreach ($action in $matchedActions) {
                                $actionStr = [string]$action
                                if (-not $deviceActionsMap[$deviceId].actions.Contains($actionStr)) {
                                    $deviceActionsMap[$deviceId].actions.Add($actionStr)
                                }
                            }
                        }
                    }

                    # Emit one edge per device with all aggregated actions
                    foreach ($entry in $deviceActionsMap.GetEnumerator()) {
                        $deviceId = $entry.Key
                        $edgeActions = @($entry.Value.actions)
                        if ($edgeActions.Count -eq 0) { continue }

                        Ensure-AZDeviceNode -Id $deviceId

                        # RoleAssignment → Device
                        Add-Edge -StartId $roleAssignmentInstanceId -EndId $deviceId -Kind $EdgeKind.IntuneRolePermission -Properties @{
                            actions            = $edgeActions
                            admintierlevel     = $entry.Value.tierLevel
                            admintierlevelname = $entry.Value.tierName
                            rbacsystem         = [string]$RbacSystem
                        }

                        # Principal → Device
                        $queryDeviceId = ([string]$deviceId).ToUpperInvariant()
                        $queryPrincipalId = ([string]$principalId).ToUpperInvariant()
                        # $principalDeviceComposition = @(
                        # "MATCH p1 = (principal)-[:AZMemberOf*1..]->(group)-[:EO_HasIntuneRoleAssignment]->(ra)-[:EO_IntuneRolePermission]->(device)"
                        # "WHERE device.objectid = '$($queryDeviceId)'"
                        # " AND principal.objectid = '$($queryDeviceId)'
                        # "OPTIONAL MATCH p2 = (ra)<-[:EO_IntuneRoleAssigned]-(role)"
                        # "RETURN p1,p2"
                        # ) -join [Environment]::NewLine
                        $principalDeviceComposition = @(
                            "MATCH p1=(principal)-[:EO_HasIntuneRoleAssignment]->(ra:EO_IntuneRoleAssignment)-[:EO_IntuneRolePermission]->(device)"
                            "WHERE device.objectid = '$($queryDeviceId)' AND principal.objectid = '$($queryPrincipalId)'"
                            "OPTIONAL MATCH p2=(ra)<-[:EO_IntuneRoleAssigned]-(:EO_IntuneRole)"
                            "OPTIONAL MATCH p3 = (group:AZGroup)-[r:EO_HasIntuneRoleAssignment]->(ra) WHERE NOT r.roleassignmentsubtype IN ['Nested Eligible group member', 'Eligible member']"
                            "OPTIONAL MATCH p4 = (principal)-[:AZMemberOf*1..]->(group)"
                            "RETURN p1,p2,p3,p4"
                        ) -join [Environment]::NewLine
                        Add-Edge -StartId $principalId -EndId $deviceId -Kind $EdgeKind.IntuneRolePermission -Properties @{
                            actions            = $edgeActions
                            admintierlevel     = $entry.Value.tierLevel
                            admintierlevelname = $entry.Value.tierName
                            rbacsystem         = [string]$RbacSystem
                            Composition        = $principalDeviceComposition
                        }
                    }
                }
            }
        }

        Write-Host " ✓ $($RbacSystem): $($Privileges.Count) principals loaded" -ForegroundColor Green
    }

    # ── Add Cypher query properties to nodes ────────────────────────────────────
    # Sets of kind names for kind-based dispatch
    $RoleNodeKindSet = [System.Collections.Generic.HashSet[string]]::new(
        [string[]]@($RoleNodeKindMap.Values | ForEach-Object { $_ })
    )
    $RoleAssignmentKindSet = [System.Collections.Generic.HashSet[string]]::new(
        [string[]]@($RoleAssignmentNodeKindMap.Values | ForEach-Object { $_ })
    )

    # All-system edge kind strings for node query shortcuts.
    $AllActiveEdgeKindsQ = $AllActiveRoleEdgeKinds
    $AllEligibleEdgeKindsQ = $AllEligibleRoleEdgeKinds
    $AllRaEdgesQ = $AllRoleAssignmentEdges
    $AllRaAssignedQ = $AllRoleAssignedEdges
    $MemberOfEdgeQ = $EdgeKind.MemberOf
    $UsesPAWEdgeQ = $EdgeKind.UsesPAW
    $PAWForEdgeQ = $EdgeKind.PAWFor
    $OwnsDeviceEdgeQ = $EdgeKind.OwnsDevice
    $DeviceOwnerEdgeQ = $EdgeKind.DeviceOwner
    $IntunePermissionEdgeQ = $EdgeKind.IntuneRolePermission
    $AssignedToAUEdgeQ = $EdgeKind.AssignedToAU
    $ScopedToEdgeQ = $EdgeKind.ScopedTo

    foreach ($nodeId in @($NodesIndex.Keys)) {
        $node = $NodesIndex[$nodeId]
        $props = $node.properties
        $nodeKinds = $node.kinds
        $rbacSystem = [string]($props['rbacsystem'] ?? '')
        if (-not $rbacSystem) {
            foreach ($candidateRbacSystem in $RoleAssignmentNodeKindMap.Keys) {
                $candidateKinds = @($RoleAssignmentNodeKindMap[$candidateRbacSystem])
                if ($nodeKinds | Where-Object { $candidateKinds -contains $_ }) {
                    $rbacSystem = $candidateRbacSystem
                    break
                }
            }
        }
        $queryNodeId = ([string]$nodeId).ToUpperInvariant()

        # Universal: every node gets inbound + outbound traversal queries
        $props['InboundRelationships'] = "MATCH p=(n)<-[r]-(m) WHERE n.objectid = '$queryNodeId' RETURN p"
        $props['OutboundRelationships'] = "MATCH p=(n)-[r]->(m) WHERE n.objectid = '$queryNodeId' RETURN p"

        # Resolve per-system or all-system edge kind strings
        if ($rbacSystem -and $HasRoleEdgeKindMap.ContainsKey($rbacSystem)) {
            $activeEdgeQ = $HasRoleEdgeKindMap[$rbacSystem]
            $eligibleEdgeQ = $EligibleEdgeKindMap[$rbacSystem]
            $raEdgeQ = $HasRoleAssignmentEdgeKindMap[$rbacSystem]
            $raAssignedQ = $RoleAssignedEdgeKindMap[$rbacSystem]
            $roleNodeQ = @($RoleNodeKindMap[$rbacSystem])[0]
            $raNodeQ = @($RoleAssignmentNodeKindMap[$rbacSystem])[0]
        } else {
            $activeEdgeQ = $AllActiveEdgeKindsQ
            $eligibleEdgeQ = $AllEligibleEdgeKindsQ
            $raEdgeQ = $AllRaEdgesQ
            $raAssignedQ = $AllRaAssignedQ
            $roleNodeQ = ($RoleNodeKindMap.Values | ForEach-Object { $_ }) -join '|'
            $raNodeQ = ($RoleAssignmentNodeKindMap.Values | ForEach-Object { $_ }) -join '|'
        }

        # Kind-specific queries
        # Role definition nodes (AZRole, EO_DefenderRole, EO_IntuneRole, etc.)
        if ($nodeKinds | Where-Object { $RoleNodeKindSet.Contains($_) }) {
            $props['ActiveAssignments'] = "MATCH p=()-[:$activeEdgeQ|$MemberOfEdgeQ*1..]->(role) WHERE role.objectid = '$queryNodeId' RETURN p"
            $props['EligibleAssignments'] = "MATCH p=()-[:$eligibleEdgeQ|$MemberOfEdgeQ*1..]->(role) WHERE role.objectid = '$queryNodeId' RETURN p"
        }
        # Role assignment nodes (EO_EntraRoleAssignment, EO_DefenderRoleAssignment, etc.)
        elseif ($nodeKinds | Where-Object { $RoleAssignmentKindSet.Contains($_) }) {
            $props['RoleAndAssignments'] = "MATCH p1 = (role:$roleNodeQ)-[:$raAssignedQ]->(ra:$raNodeQ) WHERE ra.objectid = '$queryNodeId' OPTIONAL MATCH p2 = (group:AZGroup)-[r:$raEdgeQ]->(ra) WHERE NOT r.roleassignmentsubtype IN ['Nested Eligible group member', 'Eligible member'] OPTIONAL MATCH p3 = (principal)-[:$MemberOfEdgeQ*1..]->(group) RETURN p1,p2,p3"
            $props['AssignmentScope'] = "MATCH p=(ra)-[:$ScopedToEdgeQ]->(scope) WHERE ra.objectid = '$queryNodeId' RETURN p"
        }
        # User nodes
        elseif ($nodeKinds -contains 'AZUser') {
            $props['Roles'] = "MATCH p=(principal)-[:$MemberOfEdgeQ*1..]->(:AZGroup)-[:$activeEdgeQ|$eligibleEdgeQ]->(role) WHERE principal.objectid = '$queryNodeId' RETURN p"
            $props['PAWDevices'] = "MATCH p=(principal)-[:$UsesPAWEdgeQ|$OwnsDeviceEdgeQ]->(device) WHERE principal.objectid = '$queryNodeId' RETURN p"
        }
        # Group nodes
        elseif ($nodeKinds -contains 'AZGroup') {
            $props['Roles'] = "MATCH p=(principal)-[:$activeEdgeQ|$eligibleEdgeQ]->(role) WHERE principal.objectid = '$queryNodeId' RETURN p"
        }
        # Service principal nodes
        elseif ($nodeKinds -contains 'AZServicePrincipal') {
            $props['Roles'] = "MATCH p=(principal)-[:$MemberOfEdgeQ*1..]->(:AZGroup)-[:$activeEdgeQ|$eligibleEdgeQ]->(role) WHERE principal.objectid = '$queryNodeId' RETURN p"
        }
        # Device nodes
        elseif ($nodeKinds -contains 'AZDevice') {
            $props['AssociatedPrincipals'] = "MATCH p=(device)-[:$PAWForEdgeQ|$DeviceOwnerEdgeQ]->(principal) WHERE device.objectid = '$queryNodeId' RETURN p"
            $props['InboundIntunePermissions'] = "MATCH p=(principal)-[:$IntunePermissionEdgeQ]->(device) WHERE device.objectid = '$queryNodeId' RETURN p"
        }
        # Administrative unit nodes
        elseif ($nodeKinds -contains 'EO_AdministrativeUnit') {
            $props['Members'] = "MATCH p=(n)-[:$AssignedToAUEdgeQ]->(au) WHERE au.objectid = '$queryNodeId' RETURN p"
            $props['ScopedByRoleAssignments'] = "MATCH p=(ra)-[:$ScopedToEdgeQ]->(au) WHERE au.objectid = '$queryNodeId' RETURN p"
        }
        # EO_Base stub nodes: no type-specific queries beyond the universal pair
    }

    # ── Assemble OpenGraph payload ─────────────────────────────────────────────
    $Payload = [ordered]@{
        graph = [ordered]@{
            nodes = @($NodesIndex.Values)
            edges = @($EdgesList)
        }
    }

    # ── Write output ──────────────────────────────────────────────────────────
    Write-Host ""
    Write-Host "Writing OpenGraph payload..." -ForegroundColor Cyan
    $Json = $Payload | ConvertTo-Json -Depth 10 -Compress:$false
    $Json | Out-File -FilePath $OutputPath -Encoding utf8 -Force

    $NodeCount = $NodesIndex.Count
    $EdgeCount = $EdgesList.Count
    $FileSizeKB = [Math]::Round((Get-Item $OutputPath).Length / 1KB, 1)

    Write-Host ""
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Green
    Write-Host " ✓ OpenGraph export complete" -ForegroundColor Green
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Green
    Write-Host " Nodes : $NodeCount" -ForegroundColor Gray
    Write-Host " Edges : $EdgeCount" -ForegroundColor Gray
    Write-Host " File : $OutputPath ($FileSizeKB KB)" -ForegroundColor Gray
    Write-Host ""

    return [PSCustomObject]@{
        OutputPath = $OutputPath
        NodeCount  = $NodeCount
        EdgeCount  = $EdgeCount
        FileSizeKB = $FileSizeKB
    }
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCY+8Tlu4Ux4szL
# ZUBoFWdQXBBUgFcv+YQIdAwtQKteuKCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# 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
# ARUwLwYJKoZIhvcNAQkEMSIEIGrPPHI10KNGEkujUnPic2xrdQSODeWnlYBtlCmX
# 0lgrMA0GCSqGSIb3DQEBAQUABIIBgEMunuz6xgDRcJYcW+U0l17t/DBWOUrzYQsN
# l2w29tReZp/zxU+s0biToGz3Mf+5gcLHtbe2qHkB2+mXwg86610B/9camgtbC6P0
# +92gpCEd4i894p3Q6RPf43wpND1Q3IWBrdlRrCuH4PSJBtKLo5gM2tIlOOWDAxLT
# CwMltj9ImbAtpPD2mPycxU8XPxwIIx6Ya8bPn9PSBe5fRNjOzLTiQz0cOmR+jjcJ
# p5l8tnNgEoKoh+/5olvEYfUDY9vXZ56nCiPT2zO0DTmzu5vgiGDYNOsQt6tj89G9
# Wi0c/NDfIBRhe1Ld9QgBvU8NJMAMGD6WyrWApAEyVtflK7t+ygiaPvTYA0jq3WBk
# yx8N4vOoNWDrmg4OSyAEU2KeF9RZFi6qwdd54/lDroZO8k9vLlq6i0nNOlQJIeWU
# hmPgpV4nFi8zxAaaOYjAvPH60Gqx1b5LMB83WEgWVCCiPF8QwDe5m1QNkQKDaoz2
# zz5vZitKJfzQDdqnVwKwzGiERtfaMqGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQxMDZaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDC6aoHvCgZkq2sUG5A2hxUDt/fDmLdXCuvZ
# 7gz9U9XM+Vw/3UGiVvuaFYE1uFKyJ/8wgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAbJv+RSp4mz6PtewA4t7Ps/HvMk5XHYEAlD6lVkXrLcGO1S8u
# XdqM95wuN47adL0TiOCZdIXTdXhPhJGmAHo09fsxgr4xO5/+aHK6BYqqopC4yHAz
# VuwbQ2dCYukuPzaJ9bWS4gRl1pIfP/lEpHWNZsQdjsxzmP3PymU4nwK2lEE5AYD4
# B+rzJ2nVLRNZrg0WR998ED5FVi5mSrGw3NwG9reOCI8bHH+8EJrmRQqobGr7Ot7t
# SKVzQ/qeW4bbJW8fzYfImfJbr7vfqjAYoyIZkQRFxX4hTsJt+/n8drfsOiQaQu0C
# U33iK8DIbBIX9stp1bdj739Ww/ACYr3FEGIgp7AGLdAj6zv0yWhH3/W0U99O4zVR
# 3UVp0Jbm3pndcABWJnY4ywGv6OReEeVlm1gynJaldmtky7WBYPlsfBhHkiRt1oUh
# FILP6Y0Y/s106TRod3Wg5tYC5w9cEfYx93fqOr88cQ0ZKgfJwzH7MQJm25v+V0Zr
# f3kxbEm+ti5gIRUQZaKKWMVNK1OMszE8E98tMOReJ5TwKybG4WfWJzPGWZ9PBiJm
# KOAwMXKSpBRE9ocPPhxaYtkPau2p+0taWHSrocUtqVerl/MwKvBb5OMz+boWKNom
# jd78lqqL+3hIBk1Vwe+WptSOe8I7IH4Sryq5bmltVTlQZ0yYqHDPti2gda0=
# SIG # End signature block