modules/Invoke-IdentityGraphExpansion.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Expand the identity graph with cross-tenant B2B + SPN-to-resource edges.
.DESCRIPTION
    Builds on the existing IdentityCorrelator. Adds first-class Edge records to
    the EntityStore for relationships that auditors ask about first:
 
        - GuestOf : B2B guest user -> external home tenant
        - MemberOf : User|SPN -> directory group / role
        - HasRoleOn : SPN|User -> AzureResource (RBAC role assignment)
        - OwnsAppRegistration: User|SPN -> Application
        - ConsentedTo : User|SPN -> Application (delegated/admin consent)
 
    Microsoft Graph access is OPTIONAL. When the Microsoft.Graph modules are not
    connected, the wrapper consumes pre-fetched data via -PreFetchedData (test
    fixtures, offline mode) and returns whatever can be derived from the existing
    EntityStore. All Graph calls are wrapped in Invoke-WithRetry to handle the
    aggressive 429 throttling Graph applies. All errors are sanitised.
 
    Read-only Graph scopes required when running live:
        User.Read.All, Application.Read.All, Directory.Read.All
.PARAMETER EntityStore
    Populated EntityStore. Edges are added directly via $Store.AddEdge.
.PARAMETER TenantId
    Home tenant id (for canonical Tenant entity ids).
.PARAMETER PreFetchedData
    Optional PSCustomObject with .Guests, .GroupMemberships, .AppRoleAssignments,
    .RbacAssignments, .AppOwnerships, .ConsentGrants. Used by tests; bypasses
    live Graph calls when supplied.
.PARAMETER IncludeGraphLookup
    When set and Microsoft.Graph is connected, performs live Graph queries.
.OUTPUTS
    PSCustomObject @{ Status; RunId; Findings = [...]; Edges = [...] }.
    Findings already passed through New-FindingRow. Edges already passed
    through New-Edge AND added to the supplied EntityStore (so the orchestrator
    must NOT re-add them).
 
    This is the "envelope" correlator contract (issue #187). The orchestrator
    (Invoke-AzureAnalyzer.ps1) detects the envelope shape via:
        $corrRaw -is [pscustomobject] AND has .Findings AND has (.Status OR .Edges)
    Any future correlator returning flat finding rows MUST NOT include both a
    `Findings` property AND a `Status`/`Edges` property — that combination is
    reserved for this envelope contract.
#>

[CmdletBinding()]
param ()

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

. "$PSScriptRoot\shared\Schema.ps1"
. "$PSScriptRoot\shared\Sanitize.ps1"
. "$PSScriptRoot\shared\Retry.ps1"
. "$PSScriptRoot\shared\Canonicalize.ps1"


$envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1'
if (Test-Path $envelopePath) { . $envelopePath }
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } }
$script:HighPrivilegeRoles = @(
    'owner', 'contributor', 'user access administrator',
    'role based access control administrator', 'access review operator service role'
)
$script:RiskyConsentScopes = @(
    'directory.readwrite.all', 'application.readwrite.all',
    'roleassignmentschedule.readwrite.directory', 'rolemanagement.readwrite.directory',
    'mail.read', 'mail.readwrite', 'files.readwrite.all',
    'sites.fullcontrol.all', 'user.readwrite.all'
)

function Get-DomainFromUpn {
    [CmdletBinding()]
    param ([string] $Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return $null }
    # B2B guests come back as `local#EXT#@home.onmicrosoft.com` — strip the EXT
    # suffix to recover the home domain. Fall back to plain domain split.
    if ($Value -match '#EXT#@([^@]+)$') { return $matches[1].ToLowerInvariant() }
    if ($Value -match '@([^@]+)$') { return $matches[1].ToLowerInvariant() }
    return $null
}

function Get-HomeTenantIdFromGuest {
    [CmdletBinding()]
    param ([object] $Guest)
    if (-not $Guest) { return $null }
    foreach ($prop in @('HomeTenantId', 'IssuerAssignedId', 'ExternalUserStateChangeDateTime')) {
        if ($Guest.PSObject.Properties[$prop] -and $Guest.$prop -is [string] -and $Guest.$prop -match '^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$') {
            return ($Guest.$prop).ToLowerInvariant()
        }
    }
    if ($Guest.PSObject.Properties['Identities'] -and $Guest.Identities) {
        foreach ($id in @($Guest.Identities)) {
            if ($id.PSObject.Properties['Issuer'] -and $id.Issuer -match '([0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12})') {
                return ($matches[1]).ToLowerInvariant()
            }
        }
    }
    return $null
}

function ConvertTo-CanonicalTenantToken {
    <#
    .SYNOPSIS
        Best-effort tenant canonicalization. Returns "tenant:{guid}" when a guid is
        available, else a slugified domain string ("tenant-domain:contoso.com").
    #>

    [CmdletBinding()]
    param ([string] $TenantIdOrDomain)
    if ([string]::IsNullOrWhiteSpace($TenantIdOrDomain)) { return $null }
    $value = $TenantIdOrDomain.Trim()
    try {
        $canonical = ConvertTo-CanonicalEntityId -RawId $value -EntityType 'Tenant'
        return $canonical.CanonicalId
    } catch {
        return "tenant-domain:$($value.ToLowerInvariant())"
    }
}

function Get-IdentityGraphFrameworks {
    [CmdletBinding()]
    param ()
    return @(
        @{ Name = 'NIST 800-53'; Controls = @('AC-2', 'AC-6', 'IA-2', 'IA-5'); Pillars = @('Security') },
        @{ Name = 'CIS Controls v8'; Controls = @('5.4', '6.1', '6.8'); Pillars = @('Security') }
    )
}

function Get-IdentityGraphMitre {
    [CmdletBinding()]
    param ()
    return @{
        Tactics    = @('TA0008', 'TA0004')
        Techniques = @('T1078', 'T1098')
    }
}

function Get-EntraPortalDeepLink {
    [CmdletBinding()]
    param (
        [string] $EntityId,
        [string] $EntityType
    )

    if ([string]::IsNullOrWhiteSpace($EntityId)) {
        return 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview'
    }

    switch ($EntityType) {
        'User' {
            if ($EntityId -match '^objectId:([0-9a-f-]{36})$') {
                return "https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$($matches[1])"
            }
        }
        'ServicePrincipal' {
            if ($EntityId -match '^appId:([0-9a-f-]{36})$') {
                return "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($matches[1])"
            }
        }
        'Tenant' {
            return 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview'
        }
    }

    return 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview'
}

function Invoke-IdentityGraphExpansion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [object] $EntityStore,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $TenantId,

        [PSCustomObject] $PreFetchedData,

        [switch] $IncludeGraphLookup,

        # Maximum number of principals to enumerate per collector. Prevents
        # runaway Graph/ARM calls in large tenants; principals beyond the cap
        # are not expanded. An Info finding is emitted when the cap is hit.
        [int] $MaxPrincipals = 1000
    )

    $runId = [guid]::NewGuid().ToString()
    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()
    $edges = [System.Collections.Generic.List[PSCustomObject]]::new()
    $homeTenantCanonical = ConvertTo-CanonicalTenantToken -TenantIdOrDomain $TenantId
    $frameworks = Get-IdentityGraphFrameworks
    $mitre = Get-IdentityGraphMitre
    $wrapperToolVersion = 'identity-graph-expansion@1.0'

    # ------------------------------------------------------------------
    # Data acquisition. Pre-fetched data wins; otherwise opt-in Graph calls.
    # ------------------------------------------------------------------
    $data = if ($PreFetchedData) {
        $PreFetchedData
    } else {
        Get-IdentityGraphExpansionData -IncludeGraphLookup:$IncludeGraphLookup -EntityStore $EntityStore -MaxPrincipals $MaxPrincipals
    }

    # Emit Info finding when the live collector hit the principal cap.
    if ($data -and $data.PSObject.Properties['PrincipalCapHit'] -and $data.PrincipalCapHit) {
        $capFinding = New-FindingRow `
            -Id ([guid]::NewGuid().ToString()) `
            -Source 'identity-graph-expansion' `
            -EntityId $homeTenantCanonical `
            -EntityType 'Tenant' `
            -Title "Identity Graph Expansion capped at $MaxPrincipals principals" `
            -Compliant $true `
            -ProvenanceRunId $runId `
            -Platform 'Entra' `
            -Category 'Expansion Cap' `
            -Severity 'Info' `
            -Confidence 'Confirmed' `
            -Detail "Live collector enumeration was limited to $MaxPrincipals principals from the EntityStore. Remaining principals were not expanded. Re-run with a higher -MaxPrincipals value for full coverage." `
            -Remediation 'Increase -MaxPrincipals or scope the EntityStore to fewer subscriptions/tenants.' `
            -Frameworks $frameworks `
            -Pillar 'Security' `
            -Impact 'Low' `
            -Effort 'Low' `
            -DeepLinkUrl (Get-EntraPortalDeepLink -EntityId $homeTenantCanonical -EntityType 'Tenant') `
            -RemediationSnippets @(@{ language = 'powershell'; code = "Invoke-AzureAnalyzer -IncludeTools 'identity-graph-expansion' -TenantId '$TenantId' -IncludeGraphLookup -MaxPrincipals 5000" }) `
            -EvidenceUris @('https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview') `
            -BaselineTags @('identity-graph-expansion', 'collector-cap') `
            -MitreTactics @($mitre.Tactics) `
            -MitreTechniques @($mitre.Techniques) `
            -EntityRefs @($homeTenantCanonical) `
            -ToolVersion $wrapperToolVersion
        if ($capFinding) { $findings.Add($capFinding) }
    }

    # Emit Info finding for each collector that was short-circuited by throttling.
    if ($data -and $data.PSObject.Properties['ThrottledCollectors']) {
        foreach ($collectorName in @($data.ThrottledCollectors)) {
            if (-not $collectorName) { continue }
            $throttleFinding = New-FindingRow `
                -Id ([guid]::NewGuid().ToString()) `
                -Source 'identity-graph-expansion' `
                -EntityId $homeTenantCanonical `
                -EntityType 'Tenant' `
                -Title "Collector '$collectorName' halted after 3 consecutive throttle (429) responses" `
                -Compliant $true `
                -ProvenanceRunId $runId `
                -Platform 'Entra' `
                -Category 'Throttle Skip' `
                -Severity 'Info' `
                -Confidence 'Confirmed' `
                -Detail "The '$collectorName' collector received 3 consecutive 429 responses from Microsoft Graph and was halted to avoid prolonged blocking. Partial data is included. Retry after the throttling window expires (typically 10-60 minutes)." `
                -Remediation 'Wait for the Graph throttling window to expire and re-run with -IncludeGraphLookup. Consider reducing -MaxPrincipals to lower request volume.' `
                -Frameworks $frameworks `
                -Pillar 'Security' `
                -Impact 'Low' `
                -Effort 'Low' `
                -DeepLinkUrl (Get-EntraPortalDeepLink -EntityId $homeTenantCanonical -EntityType 'Tenant') `
                -RemediationSnippets @(@{ language = 'text'; code = "Retry collector '$collectorName' after Graph throttling clears." }) `
                -EvidenceUris @('https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview') `
                -BaselineTags @('identity-graph-expansion', 'throttle-skip', "collector:$collectorName") `
                -MitreTactics @($mitre.Tactics) `
                -MitreTechniques @($mitre.Techniques) `
                -EntityRefs @($homeTenantCanonical) `
                -ToolVersion $wrapperToolVersion
            if ($throttleFinding) { $findings.Add($throttleFinding) }
        }
    }

    # ------------------------------------------------------------------
    # B2B guest -> home tenant edges + dormant guest findings
    # ------------------------------------------------------------------
    $guests = if ($data -and $data.PSObject.Properties['Guests'] -and $data.Guests) { @($data.Guests) } else { @() }
    foreach ($guest in $guests) {
        if (-not $guest) { continue }
        $oid = if ($guest.PSObject.Properties['Id']) { [string]$guest.Id } else { $null }
        $upn = if ($guest.PSObject.Properties['UserPrincipalName']) { [string]$guest.UserPrincipalName } else { $null }
        $mail = if ($guest.PSObject.Properties['Mail']) { [string]$guest.Mail } else { $null }
        $state = if ($guest.PSObject.Properties['ExternalUserState']) { [string]$guest.ExternalUserState } else { 'Unknown' }
        if (-not $oid) { continue }

        try {
            $userCanonical = (ConvertTo-CanonicalEntityId -RawId $oid -EntityType 'User').CanonicalId
        } catch {
            Write-Verbose "Skipping guest with invalid object id: $(Remove-Credentials $_.Exception.Message)"
            continue
        }

        $homeTid = Get-HomeTenantIdFromGuest -Guest $guest
        $homeDomain = Get-DomainFromUpn -Value $upn
        if (-not $homeDomain) { $homeDomain = Get-DomainFromUpn -Value $mail }

        $tenantToken = if ($homeTid) { "tenant:$homeTid" } elseif ($homeDomain) { "tenant-domain:$homeDomain" } else { $null }
        if ($tenantToken) {
            $confidence = if ($homeTid) { 'Confirmed' } elseif ($homeDomain) { 'Likely' } else { 'Unknown' }
            $edge = New-Edge `
                -Source $userCanonical `
                -Target $tenantToken `
                -Relation 'GuestOf' `
                -Confidence $confidence `
                -Platform 'Entra' `
                -DiscoveredBy 'identity-graph-expansion' `
                -Properties @{
                    ExternalUserState = $state
                    HomeDomain        = $homeDomain
                    HomeTenantId      = $homeTid
                    GuestUpn          = $upn
                }
            if ($edge) { $edges.Add($edge) }
        }

        # Risk: pending-acceptance guests are dormant attack surface
        if ($state -eq 'PendingAcceptance') {
            $finding = New-FindingRow `
                -Id ([guid]::NewGuid().ToString()) `
                -Source 'identity-graph-expansion' `
                -EntityId $userCanonical `
                -EntityType 'User' `
                -Title "Dormant B2B guest in pending-acceptance state ($($upn ?? $oid))" `
                -Compliant $false `
                -ProvenanceRunId $runId `
                -Platform 'Entra' `
                -Category 'B2B Guest Hygiene' `
                -Severity 'Low' `
                -Confidence 'Confirmed' `
                -Detail "Guest user has not accepted invitation. Home domain: $($homeDomain ?? 'unknown'). Stale invitations should be reviewed and revoked." `
                -Remediation 'Audit pending B2B invitations regularly; revoke unused invitations via Entra > External Identities.' `
                -Frameworks $frameworks `
                -Pillar 'Security' `
                -Impact 'Medium' `
                -Effort 'Low' `
                -DeepLinkUrl (Get-EntraPortalDeepLink -EntityId $userCanonical -EntityType 'User') `
                -RemediationSnippets @(@{ language = 'text'; code = 'Review guest accounts in Entra External Identities and remove stale pending invitations.' }) `
                -EvidenceUris @('https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserManagementMenuBlade/~/AllUsers') `
                -BaselineTags @('identity-graph-expansion', 'guest', "external-state:$state") `
                -MitreTactics @($mitre.Tactics) `
                -MitreTechniques @($mitre.Techniques) `
                -EntityRefs @($userCanonical, $tenantToken, $homeTenantCanonical | Where-Object { $_ } | Select-Object -Unique) `
                -ToolVersion $wrapperToolVersion
            if ($finding) { $findings.Add($finding) }
        }
    }

    # ------------------------------------------------------------------
    # MemberOf edges (group/directory role memberships)
    # ------------------------------------------------------------------
    $memberships = if ($data -and $data.PSObject.Properties['GroupMemberships'] -and $data.GroupMemberships) { @($data.GroupMemberships) } else { @() }
    foreach ($m in $memberships) {
        if (-not $m) { continue }
        $principalId = if ($m.PSObject.Properties['PrincipalId']) { [string]$m.PrincipalId } else { $null }
        $principalType = if ($m.PSObject.Properties['PrincipalType']) { [string]$m.PrincipalType } else { 'User' }
        $groupId = if ($m.PSObject.Properties['GroupId']) { [string]$m.GroupId } else { $null }
        $groupName = if ($m.PSObject.Properties['GroupName']) { [string]$m.GroupName } else { $null }
        if (-not $principalId -or -not $groupId) { continue }

        try {
            $entityType = if ($principalType -match 'service|principal|managed') { 'ServicePrincipal' } else { 'User' }
            $srcCanonical = (ConvertTo-CanonicalEntityId -RawId $principalId -EntityType $entityType).CanonicalId
        } catch { continue }

        $groupToken = "group:$($groupId.ToLowerInvariant())"
        $edge = New-Edge `
            -Source $srcCanonical `
            -Target $groupToken `
            -Relation 'MemberOf' `
            -Confidence 'Confirmed' `
            -Platform 'Entra' `
            -DiscoveredBy 'identity-graph-expansion' `
            -Properties @{ GroupName = $groupName; PrincipalType = $principalType }
        if ($edge) { $edges.Add($edge) }
    }

    # ------------------------------------------------------------------
    # HasRoleOn edges from RBAC (SPN -> AzureResource)
    # ------------------------------------------------------------------
    $rbac = if ($data -and $data.PSObject.Properties['RbacAssignments'] -and $data.RbacAssignments) { @($data.RbacAssignments) } else { @() }
    foreach ($a in $rbac) {
        if (-not $a) { continue }
        $principalId = if ($a.PSObject.Properties['PrincipalId']) { [string]$a.PrincipalId } else { $null }
        $principalType = if ($a.PSObject.Properties['PrincipalType']) { [string]$a.PrincipalType } else { 'ServicePrincipal' }
        $scope = if ($a.PSObject.Properties['Scope']) { [string]$a.Scope } else { $null }
        $roleName = if ($a.PSObject.Properties['RoleDefinitionName']) { [string]$a.RoleDefinitionName } else { $null }
        if (-not $principalId -or -not $scope -or -not $roleName) { continue }

        try {
            $entityType = if ($principalType -match 'user') { 'User' } else { 'ServicePrincipal' }
            $srcCanonical = (ConvertTo-CanonicalEntityId -RawId $principalId -EntityType $entityType).CanonicalId
        } catch { continue }

        # Subscription-scope RBAC normalises to a Subscription entity, otherwise
        # treat the scope as a generic ARM resource id.
        $tgtCanonical = $null
        $tgtType = 'AzureResource'
        if ($scope -match '^/subscriptions/([0-9a-f-]{36})$') {
            try {
                $tgtCanonical = (ConvertTo-CanonicalEntityId -RawId $matches[1] -EntityType 'Subscription').CanonicalId
                $tgtType = 'Subscription'
            } catch { $tgtCanonical = $scope.ToLowerInvariant() }
        } else {
            try { $tgtCanonical = (ConvertTo-CanonicalEntityId -RawId $scope -EntityType 'AzureResource').CanonicalId }
            catch { $tgtCanonical = $scope.ToLowerInvariant() }
        }

        $edge = New-Edge `
            -Source $srcCanonical `
            -Target $tgtCanonical `
            -Relation 'HasRoleOn' `
            -Confidence 'Confirmed' `
            -Platform 'Azure' `
            -DiscoveredBy 'identity-graph-expansion' `
            -Properties @{ RoleName = $roleName; Scope = $scope; TargetType = $tgtType; PrincipalType = $principalType }
        if ($edge) { $edges.Add($edge) }

        # Risk: high-privilege role at subscription scope (or broader)
        $isHighPriv = $script:HighPrivilegeRoles -contains $roleName.ToLowerInvariant()
        $isBroadScope = ($scope -match '^/subscriptions/[0-9a-f-]{36}$') -or ($scope -match '^/providers/Microsoft\.Management/managementGroups/')
        if ($isHighPriv -and $isBroadScope) {
            $severity = if ($roleName -match '(?i)owner') { 'High' } else { 'Medium' }
            $finding = New-FindingRow `
                -Id ([guid]::NewGuid().ToString()) `
                -Source 'identity-graph-expansion' `
                -EntityId $srcCanonical `
                -EntityType $entityType `
                -Title "Over-privileged $entityType holds '$roleName' at $scope" `
                -Compliant $false `
                -ProvenanceRunId $runId `
                -Platform 'Azure' `
                -Category 'Identity Blast Radius' `
                -Severity $severity `
                -Confidence 'Confirmed' `
                -Detail "Principal $principalId has role '$roleName' assigned at scope '$scope'. High-privilege roles at subscription or management-group scope grant broad access and should be replaced with least-privilege custom roles or PIM-eligible assignments." `
                -Remediation 'Replace standing assignment with PIM-eligible role activation, or scope role to a single resource group.' `
                -Frameworks $frameworks `
                -Pillar 'Security' `
                -Impact 'High' `
                -Effort 'Medium' `
                -DeepLinkUrl (Get-EntraPortalDeepLink -EntityId $srcCanonical -EntityType $entityType) `
                -RemediationSnippets @(@{ language = 'text'; code = "Move role '$roleName' to PIM eligibility and reduce scope from '$scope' where possible." }) `
                -EvidenceUris @("https://portal.azure.com/#@$TenantId/resource$scope", 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview') `
                -BaselineTags @('identity-graph-expansion', 'rbac', "role:$($roleName.ToLowerInvariant())") `
                -MitreTactics @($mitre.Tactics) `
                -MitreTechniques @($mitre.Techniques) `
                -EntityRefs @($srcCanonical, $tgtCanonical, $homeTenantCanonical | Where-Object { $_ } | Select-Object -Unique) `
                -ToolVersion $wrapperToolVersion
            if ($finding) { $findings.Add($finding) }
        }
    }

    # ------------------------------------------------------------------
    # OwnsAppRegistration edges
    # ------------------------------------------------------------------
    $owners = if ($data -and $data.PSObject.Properties['AppOwnerships'] -and $data.AppOwnerships) { @($data.AppOwnerships) } else { @() }
    foreach ($o in $owners) {
        if (-not $o) { continue }
        $ownerId = if ($o.PSObject.Properties['OwnerId']) { [string]$o.OwnerId } else { $null }
        $ownerType = if ($o.PSObject.Properties['OwnerType']) { [string]$o.OwnerType } else { 'User' }
        $appId = if ($o.PSObject.Properties['AppId']) { [string]$o.AppId } else { $null }
        $appName = if ($o.PSObject.Properties['AppDisplayName']) { [string]$o.AppDisplayName } else { $null }
        if (-not $ownerId -or -not $appId) { continue }

        try {
            $entityType = if ($ownerType -match 'service|principal') { 'ServicePrincipal' } else { 'User' }
            $srcCanonical = (ConvertTo-CanonicalEntityId -RawId $ownerId -EntityType $entityType).CanonicalId
            $tgtCanonical = (ConvertTo-CanonicalEntityId -RawId $appId -EntityType 'Application').CanonicalId
        } catch { continue }

        $edge = New-Edge `
            -Source $srcCanonical `
            -Target $tgtCanonical `
            -Relation 'OwnsAppRegistration' `
            -Confidence 'Confirmed' `
            -Platform 'Entra' `
            -DiscoveredBy 'identity-graph-expansion' `
            -Properties @{ AppDisplayName = $appName; OwnerType = $ownerType }
        if ($edge) { $edges.Add($edge) }
    }

    # ------------------------------------------------------------------
    # ConsentedTo edges + risky-consent findings
    # ------------------------------------------------------------------
    $consents = if ($data -and $data.PSObject.Properties['ConsentGrants'] -and $data.ConsentGrants) { @($data.ConsentGrants) } else { @() }
    foreach ($g in $consents) {
        if (-not $g) { continue }
        $clientId = if ($g.PSObject.Properties['ClientId']) { [string]$g.ClientId } else { $null }
        $resourceId = if ($g.PSObject.Properties['ResourceId']) { [string]$g.ResourceId } else { $null }
        $consentType = if ($g.PSObject.Properties['ConsentType']) { [string]$g.ConsentType } else { 'AllPrincipals' }
        $scope = if ($g.PSObject.Properties['Scope']) { [string]$g.Scope } else { '' }
        if (-not $clientId -or -not $resourceId) { continue }

        try {
            $srcCanonical = (ConvertTo-CanonicalEntityId -RawId $clientId -EntityType 'ServicePrincipal').CanonicalId
            $tgtCanonical = (ConvertTo-CanonicalEntityId -RawId $resourceId -EntityType 'Application').CanonicalId
        } catch { continue }

        $edge = New-Edge `
            -Source $srcCanonical `
            -Target $tgtCanonical `
            -Relation 'ConsentedTo' `
            -Confidence 'Confirmed' `
            -Platform 'Entra' `
            -DiscoveredBy 'identity-graph-expansion' `
            -Properties @{ ConsentType = $consentType; Scope = $scope }
        if ($edge) { $edges.Add($edge) }

        # Risk: tenant-wide consent to risky scopes
        $scopeTokens = @($scope -split '\s+' | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() })
        $risky = @($scopeTokens | Where-Object { $script:RiskyConsentScopes -contains $_ })
        if ($consentType -eq 'AllPrincipals' -and $risky.Count -gt 0) {
            $finding = New-FindingRow `
                -Id ([guid]::NewGuid().ToString()) `
                -Source 'identity-graph-expansion' `
                -EntityId $srcCanonical `
                -EntityType 'ServicePrincipal' `
                -Title "Tenant-wide admin consent for risky scopes: $($risky -join ', ')" `
                -Compliant $false `
                -ProvenanceRunId $runId `
                -Platform 'Entra' `
                -Category 'Excessive Consent' `
                -Severity 'High' `
                -Confidence 'Confirmed' `
                -Detail "Application $clientId holds tenant-wide admin consent to high-impact scopes ($($risky -join ', ')) on resource $resourceId. Tenant-wide admin consent should be reserved for first-party Microsoft apps and explicitly approved third parties." `
                -Remediation 'Review the consent grant in Entra > Enterprise Applications > Permissions and revoke if not justified.' `
                -Frameworks $frameworks `
                -Pillar 'Security' `
                -Impact 'High' `
                -Effort 'Medium' `
                -DeepLinkUrl (Get-EntraPortalDeepLink -EntityId $srcCanonical -EntityType 'ServicePrincipal') `
                -RemediationSnippets @(@{ language = 'text'; code = "Review and revoke risky scopes ($($risky -join ', ')) in Entra Enterprise Applications permissions." }) `
                -EvidenceUris @('https://entra.microsoft.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview') `
                -BaselineTags @('identity-graph-expansion', 'consent', 'tenant-wide-admin-consent') `
                -MitreTactics @($mitre.Tactics) `
                -MitreTechniques @($mitre.Techniques) `
                -EntityRefs @($srcCanonical, $tgtCanonical, $homeTenantCanonical | Where-Object { $_ } | Select-Object -Unique) `
                -ToolVersion $wrapperToolVersion
            if ($finding) { $findings.Add($finding) }
        }
    }

    # Persist edges to the supplied store (when one was provided and supports it)
    $store = $EntityStore
    if ($store -and $store.PSObject.Methods['AddEdge']) {
        foreach ($e in $edges) {
            try { $store.AddEdge($e) }
            catch {
                $msg = if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) {
                    Remove-Credentials $_.Exception.Message
                } else { $_.Exception.Message }
                Write-Warning "AddEdge failed for $($e.EdgeId): $msg"
            }
        }
    }

    $expansionSummary = [object[]] @()
    if ($data -and $data.PSObject.Properties['ExpansionSummary'] -and $data.ExpansionSummary) {
        $expansionSummary = @($data.ExpansionSummary)
    }

    Write-Verbose "IdentityGraphExpansion: emitted $($findings.Count) finding(s) and $($edges.Count) edge(s)."
    return [PSCustomObject]@{
        Status           = 'Success'
        RunId            = $runId
        ToolVersion      = $wrapperToolVersion
        Findings         = @($findings)
        Errors   = @()
        Edges            = @($edges)
        ExpansionSummary = $expansionSummary
    }
}

function Get-IdentityGraphExpansionData {
    <#
    .SYNOPSIS
        Live Microsoft Graph + ARM data acquisition. Always wrapped in retry.
    .DESCRIPTION
        Returns an object with Guests/GroupMemberships/RbacAssignments/AppOwnerships/ConsentGrants
        arrays. Skips gracefully when modules / context are missing.
    #>

    [CmdletBinding()]
    param (
        [switch] $IncludeGraphLookup,
        [object] $EntityStore,
        [int]    $MaxPrincipals = 1000
    )

    $expansionSummaryList = [System.Collections.Generic.List[PSCustomObject]]::new()
    $throttledCollectors  = [System.Collections.Generic.List[string]]::new()

    $result = [PSCustomObject]@{
        Guests              = @()
        GroupMemberships    = @()
        RbacAssignments     = @()
        AppOwnerships       = @()
        ConsentGrants       = @()
        ExpansionSummary    = $expansionSummaryList
        PrincipalCapHit     = $false
        ThrottledCollectors = $throttledCollectors
    }

    if (-not $IncludeGraphLookup) {
        Write-Verbose 'IdentityGraphExpansion: -IncludeGraphLookup not set; returning empty live data.'
        return $result
    }

    $mg = Get-Command -Name 'Get-MgUser' -ErrorAction SilentlyContinue
    if (-not $mg) {
        Write-Warning 'IdentityGraphExpansion: Microsoft.Graph.Users module not loaded; skipping live Graph queries.'
        return $result
    }

    # ---- Guests (tenant-wide) ----
    try {
        $guests = Invoke-WithRetry -ScriptBlock {
            Get-MgUser -Filter "userType eq 'Guest'" -All `
                -Property 'id,userPrincipalName,mail,externalUserState,identities' `
                -ErrorAction Stop
        }
        if ($guests) { $result.Guests = @($guests) }
    } catch {
        Write-Warning "IdentityGraphExpansion: Guest query failed: $(Remove-Credentials $_.Exception.Message)"
    }

    # ---- Candidate extraction from EntityStore (candidate-driven expansion) ----
    # Only enumerate for principals already in the EntityStore (O(P) API calls,
    # where P = known principals). Avoids full-tenant enumeration of group members.
    $userOids  = [System.Collections.Generic.List[string]]::new()
    $spnAppIds = [System.Collections.Generic.List[string]]::new()

    if ($EntityStore) {
        try {
            $allEntities = @($EntityStore.GetEntities())
        } catch {
            $allEntities = @()
            Write-Verbose "IdentityGraphExpansion: EntityStore.GetEntities() failed: $(Remove-Credentials $_.Exception.Message)"
        }
        foreach ($entity in $allEntities) {
            if (-not $entity) { continue }
            switch ($entity.EntityType) {
                'User' {
                    if ($entity.EntityId -match '^objectId:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$') {
                        $userOids.Add($matches[1])
                    }
                }
                'ServicePrincipal' {
                    if ($entity.EntityId -match '^appId:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$') {
                        $spnAppIds.Add($matches[1])
                    }
                }
            }
        }
    }

    $userOids  = @($userOids  | Select-Object -Unique)
    $spnAppIds = @($spnAppIds | Select-Object -Unique)

    # Apply principal cap (split proportionally between users and SPNs).
    $totalPrincipals = $userOids.Count + $spnAppIds.Count
    if ($MaxPrincipals -gt 0 -and $totalPrincipals -gt $MaxPrincipals) {
        Write-Warning "IdentityGraphExpansion: principal count ($totalPrincipals) exceeds cap ($MaxPrincipals); truncating to avoid excessive Graph/ARM calls."
        $result.PrincipalCapHit = $true
        $userSlots = if ($totalPrincipals -gt 0) { [math]::Min($userOids.Count, [int][math]::Ceiling($MaxPrincipals * ($userOids.Count / $totalPrincipals))) } else { 0 }
        $spnSlots  = $MaxPrincipals - $userSlots
        $userOids  = @(if ($userSlots  -gt 0) { $userOids[0..($userSlots  - 1)] })
        $spnAppIds = @(if ($spnSlots   -gt 0) { $spnAppIds[0..($spnSlots  - 1)] })
    }

    # Resolve SPN appId -> objectId (Graph and ARM APIs need the object ID).
    $spnObjectIds = @{}
    if ($spnAppIds.Count -gt 0 -and (Get-Command 'Get-MgServicePrincipal' -ErrorAction SilentlyContinue)) {
        foreach ($appId in $spnAppIds) {
            try {
                $spnRaw = Invoke-WithRetry -ScriptBlock {
                    Get-MgServicePrincipal -Filter "appId eq '$appId'" -Select 'id,appId' -Top 1 -ErrorAction Stop
                }
                if ($spnRaw -and $spnRaw.Id) { $spnObjectIds[$appId] = [string]$spnRaw.Id }
            } catch {
                Write-Warning "IdentityGraphExpansion: SPN objectId lookup failed for appId ${appId}: $(Remove-Credentials $_.Exception.Message)"
            }
        }
    }
    $spnOids = @($spnObjectIds.Values)

    # ---- GroupMemberships collector ----
    # Requires: Microsoft.Graph.Groups (Get-MgUserMemberOf) and/or
    # Microsoft.Graph.Applications (Get-MgServicePrincipalMemberOf).
    $hasMgUserMemberOf = Get-Command 'Get-MgUserMemberOf'            -ErrorAction SilentlyContinue
    $hasMgSpnMemberOf  = Get-Command 'Get-MgServicePrincipalMemberOf' -ErrorAction SilentlyContinue
    if ($hasMgUserMemberOf -or $hasMgSpnMemberOf) {
        $memberships    = [System.Collections.Generic.List[PSCustomObject]]::new()
        $consecutive429 = 0
        $throttled      = $false
        $principalQueue = @(
            @($userOids | ForEach-Object { [PSCustomObject]@{ Oid = $_; Type = 'User' } })
            @($spnOids  | ForEach-Object { [PSCustomObject]@{ Oid = $_; Type = 'ServicePrincipal' } })
        ) | Where-Object { $_ }
        foreach ($p in $principalQueue) {
            if ($throttled) { break }
            try {
                $rawItems = if ($p.Type -eq 'User' -and $hasMgUserMemberOf) {
                    Invoke-WithRetry -ScriptBlock { Get-MgUserMemberOf -UserId $p.Oid -Property 'id,displayName' -All -ErrorAction Stop }
                } elseif ($p.Type -eq 'ServicePrincipal' -and $hasMgSpnMemberOf) {
                    Invoke-WithRetry -ScriptBlock { Get-MgServicePrincipalMemberOf -ServicePrincipalId $p.Oid -Property 'id,displayName' -All -ErrorAction Stop }
                } else { @() }
                $consecutive429 = 0
                foreach ($m in @($rawItems)) {
                    if (-not $m -or -not $m.Id) { continue }
                    $memberships.Add([PSCustomObject]@{
                        PrincipalId   = $p.Oid
                        PrincipalType = $p.Type
                        GroupId       = [string]$m.Id
                        GroupName     = if ($m.PSObject.Properties['DisplayName'] -and $m.DisplayName) { [string]$m.DisplayName } else { '' }
                    })
                }
            } catch {
                $errMsg = Remove-Credentials $_.Exception.Message
                if ($errMsg -match '\b429\b|throttl|rate.?limit') {
                    $consecutive429++
                    Write-Warning "IdentityGraphExpansion: GroupMemberships throttled for $($p.Oid) ($consecutive429/3): $errMsg"
                    if ($consecutive429 -ge 3) { $throttled = $true; $throttledCollectors.Add('GroupMemberships'); break }
                } else {
                    Write-Warning "IdentityGraphExpansion: GroupMemberships failed for $($p.Oid): $errMsg"
                    $consecutive429 = 0
                }
            }
        }
        $result.GroupMemberships = @($memberships)
        $expansionSummaryList.Add([PSCustomObject]@{
            Collector      = 'GroupMemberships'
            PrincipalCount = @($principalQueue).Count
            EdgeCount      = $memberships.Count
            Skipped        = $throttled
            SkipReason     = if ($throttled) { '3 consecutive 429 responses from Microsoft Graph; retry after throttling window' } else { $null }
        })
    } else {
        Write-Warning 'IdentityGraphExpansion: Get-MgUserMemberOf not available; skipping GroupMemberships collector.'
        $expansionSummaryList.Add([PSCustomObject]@{ Collector = 'GroupMemberships'; PrincipalCount = 0; EdgeCount = 0; Skipped = $true; SkipReason = 'Microsoft.Graph.Groups module not loaded' })
    }

    # ---- RbacAssignments collector (Azure ARM RBAC via Az.Resources) ----
    # Uses Get-AzRoleAssignment (ARM scopes) NOT Get-MgRoleManagementDirectoryRoleAssignment
    # (which returns Entra ID directory roles at scope="/", not ARM resource scopes).
    $hasAzRoleAssignment = Get-Command 'Get-AzRoleAssignment' -ErrorAction SilentlyContinue
    if ($hasAzRoleAssignment) {
        $rbacList       = [System.Collections.Generic.List[PSCustomObject]]::new()
        $consecutive429 = 0
        $throttled      = $false
        $allPrincipals  = @(
            @($userOids | ForEach-Object { [PSCustomObject]@{ Oid = $_; Type = 'User' } })
            @($spnOids  | ForEach-Object { [PSCustomObject]@{ Oid = $_; Type = 'ServicePrincipal' } })
        ) | Where-Object { $_ }
        foreach ($p in $allPrincipals) {
            if ($throttled) { break }
            try {
                $assignments = Invoke-WithRetry -ScriptBlock { Get-AzRoleAssignment -ObjectId $p.Oid -ErrorAction Stop }
                $consecutive429 = 0
                foreach ($a in @($assignments)) {
                    if (-not $a) { continue }
                    $rbacList.Add([PSCustomObject]@{
                        PrincipalId        = $p.Oid
                        PrincipalType      = $p.Type
                        Scope              = if ($a.PSObject.Properties['Scope'])              { [string]$a.Scope }              else { $null }
                        RoleDefinitionName = if ($a.PSObject.Properties['RoleDefinitionName']) { [string]$a.RoleDefinitionName } else { $null }
                    })
                }
            } catch {
                $errMsg = Remove-Credentials $_.Exception.Message
                if ($errMsg -match '\b429\b|throttl|rate.?limit') {
                    $consecutive429++
                    Write-Warning "IdentityGraphExpansion: RbacAssignments throttled for $($p.Oid) ($consecutive429/3): $errMsg"
                    if ($consecutive429 -ge 3) { $throttled = $true; $throttledCollectors.Add('RbacAssignments'); break }
                } else {
                    Write-Warning "IdentityGraphExpansion: RbacAssignments failed for $($p.Oid): $errMsg"
                    $consecutive429 = 0
                }
            }
        }
        $result.RbacAssignments = @($rbacList)
        $expansionSummaryList.Add([PSCustomObject]@{
            Collector      = 'RbacAssignments'
            PrincipalCount = @($allPrincipals).Count
            EdgeCount      = $rbacList.Count
            Skipped        = $throttled
            SkipReason     = if ($throttled) { '3 consecutive 429 responses from ARM; retry after throttling window' } else { $null }
        })
    } else {
        Write-Warning 'IdentityGraphExpansion: Get-AzRoleAssignment not available (Az.Resources not loaded); skipping RbacAssignments collector.'
        $expansionSummaryList.Add([PSCustomObject]@{ Collector = 'RbacAssignments'; PrincipalCount = 0; EdgeCount = 0; Skipped = $true; SkipReason = 'Az.Resources module not loaded' })
    }

    # ---- AppOwnerships collector ----
    # Requires Microsoft.Graph.Applications.
    $hasMgUserOwnedApp   = Get-Command 'Get-MgUserOwnedApplication'       -ErrorAction SilentlyContinue
    $hasMgSpnOwnedObject = Get-Command 'Get-MgServicePrincipalOwnedObject' -ErrorAction SilentlyContinue
    if ($hasMgUserOwnedApp -or $hasMgSpnOwnedObject) {
        $ownershipList  = [System.Collections.Generic.List[PSCustomObject]]::new()
        $consecutive429 = 0
        $throttled      = $false
        $principalQueue = @(
            @($userOids | ForEach-Object { [PSCustomObject]@{ Oid = $_; Type = 'User' } })
            @($spnOids  | ForEach-Object { [PSCustomObject]@{ Oid = $_; Type = 'ServicePrincipal' } })
        ) | Where-Object { $_ }
        foreach ($p in $principalQueue) {
            if ($throttled) { break }
            try {
                $ownedApps = if ($p.Type -eq 'User' -and $hasMgUserOwnedApp) {
                    Invoke-WithRetry -ScriptBlock { Get-MgUserOwnedApplication -UserId $p.Oid -Property 'id,displayName' -All -ErrorAction Stop }
                } elseif ($p.Type -eq 'ServicePrincipal' -and $hasMgSpnOwnedObject) {
                    Invoke-WithRetry -ScriptBlock { Get-MgServicePrincipalOwnedObject -ServicePrincipalId $p.Oid -Property 'id,displayName' -All -ErrorAction Stop }
                } else { @() }
                $consecutive429 = 0
                foreach ($app in @($ownedApps)) {
                    if (-not $app -or -not $app.Id) { continue }
                    $ownershipList.Add([PSCustomObject]@{
                        OwnerId        = $p.Oid
                        OwnerType      = $p.Type
                        AppId          = [string]$app.Id
                        AppDisplayName = if ($app.PSObject.Properties['DisplayName'] -and $app.DisplayName) { [string]$app.DisplayName } else { '' }
                    })
                }
            } catch {
                $errMsg = Remove-Credentials $_.Exception.Message
                if ($errMsg -match '\b429\b|throttl|rate.?limit') {
                    $consecutive429++
                    Write-Warning "IdentityGraphExpansion: AppOwnerships throttled for $($p.Oid) ($consecutive429/3): $errMsg"
                    if ($consecutive429 -ge 3) { $throttled = $true; $throttledCollectors.Add('AppOwnerships'); break }
                } else {
                    Write-Warning "IdentityGraphExpansion: AppOwnerships failed for $($p.Oid): $errMsg"
                    $consecutive429 = 0
                }
            }
        }
        $result.AppOwnerships = @($ownershipList)
        $expansionSummaryList.Add([PSCustomObject]@{
            Collector      = 'AppOwnerships'
            PrincipalCount = @($principalQueue).Count
            EdgeCount      = $ownershipList.Count
            Skipped        = $throttled
            SkipReason     = if ($throttled) { '3 consecutive 429 responses from Microsoft Graph; retry after throttling window' } else { $null }
        })
    } else {
        Write-Warning 'IdentityGraphExpansion: Get-MgUserOwnedApplication not available; skipping AppOwnerships collector.'
        $expansionSummaryList.Add([PSCustomObject]@{ Collector = 'AppOwnerships'; PrincipalCount = 0; EdgeCount = 0; Skipped = $true; SkipReason = 'Microsoft.Graph.Applications module not loaded' })
    }

    # ---- ConsentGrants collector (bulk: single tenant-wide call, filter client-side) ----
    # Unlike other collectors, this is O(1) calls regardless of principal count.
    $hasMgOAuth2Grant = Get-Command 'Get-MgOAuth2PermissionGrant' -ErrorAction SilentlyContinue
    if ($hasMgOAuth2Grant) {
        try {
            $allGrants = Invoke-WithRetry -ScriptBlock {
                Get-MgOAuth2PermissionGrant -All -Property 'clientId,resourceId,consentType,scope' -ErrorAction Stop
            }
            $result.ConsentGrants = @($allGrants | ForEach-Object {
                if (-not $_) { return }
                [PSCustomObject]@{
                    ClientId    = if ($_.PSObject.Properties['ClientId']    -and $_.ClientId)    { [string]$_.ClientId }    else { '' }
                    ResourceId  = if ($_.PSObject.Properties['ResourceId']  -and $_.ResourceId)  { [string]$_.ResourceId }  else { '' }
                    ConsentType = if ($_.PSObject.Properties['ConsentType'] -and $_.ConsentType) { [string]$_.ConsentType } else { '' }
                    Scope       = if ($_.PSObject.Properties['Scope']       -and $_.Scope)       { [string]$_.Scope }       else { '' }
                }
            } | Where-Object { $_ })
            $expansionSummaryList.Add([PSCustomObject]@{
                Collector      = 'ConsentGrants'
                PrincipalCount = 0
                EdgeCount      = $result.ConsentGrants.Count
                Skipped        = $false
                SkipReason     = $null
            })
        } catch {
            $errMsg = Remove-Credentials $_.Exception.Message
            Write-Warning "IdentityGraphExpansion: ConsentGrants query failed: $errMsg"
            $expansionSummaryList.Add([PSCustomObject]@{ Collector = 'ConsentGrants'; PrincipalCount = 0; EdgeCount = 0; Skipped = $true; SkipReason = "Query failed: $errMsg" })
        }
    } else {
        Write-Warning 'IdentityGraphExpansion: Get-MgOAuth2PermissionGrant not available; skipping ConsentGrants collector.'
        $expansionSummaryList.Add([PSCustomObject]@{ Collector = 'ConsentGrants'; PrincipalCount = 0; EdgeCount = 0; Skipped = $true; SkipReason = 'Microsoft.Graph.Identity.SignIns module not loaded' })
    }

    return $result
}