modules/Devolutions.CIEM.Graph/Public/Get-CIEMIdentityRiskSummary.ps1

function Get-CIEMIdentityRiskSummary {
    <#
    .SYNOPSIS
        Returns identity risk summary data for all identities (users, SPs, managed identities, groups).
    .DESCRIPTION
        Queries graph_nodes joined with graph_edges (HasRole/InheritedRole) to produce a summary row
        per identity with entitlement counts, privileged counts, and computed risk level.
        Identity metadata (accountEnabled, daysSinceSignIn, sign-in dates) is read from node properties JSON.
        Privilege flag is read from edge properties JSON.
    .PARAMETER PrincipalType
        Filter by identity type: User, ServicePrincipal, ManagedIdentity, or Group.
    .PARAMETER RiskLevel
        Filter by computed risk level: Critical, High, Medium, or Low.
    .PARAMETER SubscriptionId
        Limit to role assignments scoped to a specific subscription.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter()]
        [ValidateSet('User', 'ServicePrincipal', 'ManagedIdentity', 'Group')]
        [string]$PrincipalType,

        [Parameter()]
        [ValidateSet('Critical', 'High', 'Medium', 'Low')]
        [string]$RiskLevel,

        [Parameter()]
        [string]$SubscriptionId
    )

    $ErrorActionPreference = 'Stop'

    if ($null -eq $script:DormantPermissionThresholdDays) {
        throw 'Module variable $script:DormantPermissionThresholdDays is not initialized. The module may not have loaded correctly.'
    }
    if ($null -eq $script:MediumEntitlementThreshold) {
        throw 'Module variable $script:MediumEntitlementThreshold is not initialized. The module may not have loaded correctly.'
    }

    # Map PrincipalType parameter values to graph node kinds
    $principalTypeToKind = @{
        User             = 'EntraUser'
        ServicePrincipal = 'EntraServicePrincipal'
        ManagedIdentity  = 'EntraManagedIdentity'
        Group            = 'EntraGroup'
    }

    # Map node kinds back to PrincipalType output values
    $kindToPrincipalType = @{
        EntraUser             = 'User'
        EntraServicePrincipal = 'ServicePrincipal'
        EntraManagedIdentity  = 'ManagedIdentity'
        EntraGroup            = 'Group'
    }

    # Build WHERE clause for identity node kinds
    $whereClauses = @("n.kind IN ('EntraUser', 'EntraServicePrincipal', 'EntraManagedIdentity', 'EntraGroup')")
    $parameters = @{}

    if ($PrincipalType) {
        $nodeKind = $principalTypeToKind[$PrincipalType]
        $whereClauses += "n.kind = @nodeKind"
        $parameters['nodeKind'] = $nodeKind
    }

    # Build edge JOIN condition for scope filter
    $edgeJoinCondition = "e.source_id = n.id AND e.kind IN ('HasRole', 'InheritedRole')"
    if ($SubscriptionId) {
        $edgeJoinCondition += " AND json_extract(e.properties, '$.scope') LIKE '/subscriptions/' || @subId || '%'"
        $parameters['subId'] = $SubscriptionId
    }

    $whereString = $whereClauses -join ' AND '

    # Fetch per-identity rows with per-edge detail
    $sql = @"
SELECT
    n.id,
    n.kind,
    n.display_name,
    n.properties AS node_properties,
    n.collected_at,
    e.kind AS edge_kind,
    e.properties AS edge_properties
FROM graph_nodes n
LEFT JOIN graph_edges e
    ON $edgeJoinCondition
WHERE $whereString
"@


    $rows = @(Invoke-CIEMQuery -Query $sql -Parameters $parameters)

    # Group by identity and compute counts
    $grouped = $rows | Group-Object -Property id

    $results = foreach ($group in $grouped) {
        $first = $group.Group[0]

        # Read identity metadata from node properties JSON
        $nodeProps = if ($first.node_properties) {
            $first.node_properties | ConvertFrom-Json
        } else {
            [PSCustomObject]@{}
        }

        $accountEnabled = if ($null -ne $nodeProps.accountEnabled) { [bool]$nodeProps.accountEnabled } else { $true }
        $daysSinceSignIn = $nodeProps.daysSinceSignIn
        $lastSignIn = $nodeProps.lastSignIn
        $lastInteractiveSignIn = $nodeProps.lastInteractiveSignIn
        $lastNonInteractiveSignIn = $nodeProps.lastNonInteractiveSignIn
        $principalTypeValue = $kindToPrincipalType[$first.kind]

        # Count assignments using edge properties for privilege detection
        $entitlementCount = 0
        $privilegedCount = 0
        $privilegedSubScopeCount = 0
        $inheritedCount = 0

        foreach ($row in $group.Group) {
            # Skip the LEFT JOIN null row (identity with no role edges)
            if (-not $row.edge_kind) { continue }

            $entitlementCount++

            $edgeProps = if ($row.edge_properties) {
                $row.edge_properties | ConvertFrom-Json
            } else {
                [PSCustomObject]@{}
            }

            $isPrivileged = if ($null -ne $edgeProps.privileged) { [bool]$edgeProps.privileged } else { $false }
            if ($isPrivileged) {
                $privilegedCount++
                # Check subscription-level scope (not resource group)
                $scope = $edgeProps.scope
                if ($scope -match '^/subscriptions/[^/]+$') {
                    $privilegedSubScopeCount++
                }
            }

            if ($row.edge_kind -eq 'InheritedRole') {
                $inheritedCount++
            }
        }

        # Compute risk level
        $computedRiskLevel = if (($privilegedSubScopeCount -gt 0) -and ($null -eq $daysSinceSignIn -or $daysSinceSignIn -gt $script:DormantPermissionThresholdDays -or -not $accountEnabled)) {
            'Critical'
        } elseif ($privilegedSubScopeCount -gt 0 -or ($inheritedCount -gt 0 -and $privilegedCount -gt 0)) {
            'High'
        } elseif ($privilegedCount -gt 0 -or $entitlementCount -gt $script:MediumEntitlementThreshold) {
            'Medium'
        } else {
            'Low'
        }

        [PSCustomObject]@{
            Id                       = $first.id
            DisplayName              = $first.display_name
            PrincipalType            = $principalTypeValue
            AccountEnabled           = $accountEnabled
            EntitlementCount         = $entitlementCount
            PrivilegedCount          = $privilegedCount
            InheritedCount           = $inheritedCount
            LastSignIn               = $lastSignIn
            DaysSinceSignIn          = $daysSinceSignIn
            LastInteractiveSignIn    = $lastInteractiveSignIn
            LastNonInteractiveSignIn = $lastNonInteractiveSignIn
            RiskLevel                = $computedRiskLevel
        }
    }

    # Post-filter: RiskLevel is computed in PowerShell, cannot be pushed to SQL
    if ($RiskLevel) {
        $results = @($results | Where-Object { $_.RiskLevel -eq $RiskLevel })
    }

    @($results)
}