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

function GetCIEMDashboardSeverityRank {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Severity
    )

    $ErrorActionPreference = 'Stop'

    $normalized = $Severity.ToLowerInvariant()
    switch ($normalized) {
        'critical' { 1 }
        'high' { 2 }
        'medium' { 3 }
        'low' { 4 }
        default { throw "Unsupported dashboard severity '$Severity'." }
    }
}

function ConvertToCIEMDashboardSeverityLabel {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Severity
    )

    $ErrorActionPreference = 'Stop'

    $normalized = $Severity.ToLowerInvariant()
    switch ($normalized) {
        'critical' { 'Critical' }
        'high' { 'High' }
        'medium' { 'Medium' }
        'low' { 'Low' }
        default { throw "Unsupported dashboard severity '$Severity'." }
    }
}

function GetCIEMDashboardSignalRank {
    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$Signal
    )

    $ErrorActionPreference = 'Stop'

    switch ($Signal) {
        'managed-identity-public-exposure' { 1 }
        'dormant-privileged-permissions' { 2 }
        'disabled-with-permissions' { 3 }
        'group-inherited-privileged-role' { 4 }
        'privileged-standing-access' { 5 }
        'attack-path' { 6 }
        default { 7 }
    }
}

function GetCIEMDashboardPrimaryRiskSignal {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$RiskSignals
    )

    $ErrorActionPreference = 'Stop'

    @($RiskSignals |
        Sort-Object `
            @{ Expression = { GetCIEMDashboardSeverityRank -Severity ([string]$_.Severity) } }, `
            @{ Expression = { GetCIEMDashboardSignalRank -Signal ([string]$_.Signal) } } |
        Select-Object -First 1)
}

function GetCIEMDashboardIdentityTarget {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$RoleAssignments,

        [Parameter()]
        [object]$HostingResource
    )

    $ErrorActionPreference = 'Stop'

    if ($HostingResource -and [bool]$HostingResource.HasPublicIP) {
        return [PSCustomObject]@{
            Id   = [string]$HostingResource.Id
            Name = [string]$HostingResource.Name
        }
    }

    $assignment = @($RoleAssignments |
        Sort-Object `
            @{ Expression = { if ([bool]$_.IsPrivileged) { 0 } else { 1 } } }, `
            @{ Expression = { [string]$_.Scope } } |
        Select-Object -First 1)

    if ($assignment.Count -eq 0) {
        return [PSCustomObject]@{
            Id   = ''
            Name = 'No active assignment target'
        }
    }

    [PSCustomObject]@{
        Id   = [string]$assignment[0].Scope
        Name = [string]$assignment[0].Scope
    }
}

function GetCIEMDashboardIdentityReason {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Summary,

        [Parameter()]
        [object]$PrimarySignal
    )

    $ErrorActionPreference = 'Stop'

    if ($PrimarySignal) {
        return [string]$PrimarySignal.Description
    }

    if ([int]$Summary.PrivilegedCount -gt 0) {
        return "Holds $($Summary.PrivilegedCount) privileged role assignment(s)"
    }

    if ([int]$Summary.InheritedCount -gt 0) {
        return "Holds $($Summary.InheritedCount) inherited role assignment(s)"
    }

    "Holds $($Summary.EntitlementCount) active entitlement(s)"
}

function GetCIEMDashboardIdentityEvidence {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Summary,

        [Parameter(Mandatory)]
        [string]$Target
    )

    $ErrorActionPreference = 'Stop'

    $parts = @(
        "$($Summary.EntitlementCount) entitlement(s)"
        "$($Summary.PrivilegedCount) privileged"
        "$($Summary.InheritedCount) inherited"
    )

    if ($Summary.DaysSinceSignIn -ne $null) {
        $parts += "$($Summary.DaysSinceSignIn) day(s) since sign-in"
    }

    $parts += "target $Target"
    $parts -join '; '
}

function GetCIEMDashboardAttackPathIdentityMetadata {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$AttackPath
    )

    $ErrorActionPreference = 'Stop'

    $identityKinds = @('EntraUser', 'EntraServicePrincipal', 'EntraManagedIdentity', 'EntraGroup')
    $identityNode = @($AttackPath.Path | Where-Object { $identityKinds -contains [string]$_.kind } | Select-Object -First 1)
    if ($identityNode.Count -eq 0) {
        return [PSCustomObject]@{
            Id   = ''
            Name = ''
            Type = ''
        }
    }

    $identityType = switch ([string]$identityNode[0].kind) {
        'EntraUser' { 'User'; break }
        'EntraServicePrincipal' { 'ServicePrincipal'; break }
        'EntraManagedIdentity' { 'ManagedIdentity'; break }
        'EntraGroup' { 'Group'; break }
        default { throw "Unsupported attack path identity kind '$($identityNode[0].kind)'." }
    }

    $identityName = if ($identityNode[0].display_name) {
        [string]$identityNode[0].display_name
    }
    else {
        [string]$identityNode[0].id
    }

    [PSCustomObject]@{
        Id   = [string]$identityNode[0].id
        Name = $identityName
        Type = $identityType
    }
}

function GetCIEMDashboardAttackPathTarget {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$AttackPath
    )

    $ErrorActionPreference = 'Stop'

    $pathNodes = @($AttackPath.Path)
    if ($pathNodes.Count -eq 0) {
        throw "Attack path '$($AttackPath.Id)' has no path nodes."
    }

    $targetNode = $pathNodes[$pathNodes.Count - 1]
    $targetId = [string]$targetNode.id
    $targetName = if ($targetNode.display_name) {
        [string]$targetNode.display_name
    }
    else {
        $targetId
    }

    [PSCustomObject]@{
        Id   = $targetId
        Name = $targetName
    }
}

function Get-CIEMDashboardNeedsAttention {
    <#
    .SYNOPSIS
        Returns the highest-priority current risks for the dashboard Needs Attention queue.
    .DESCRIPTION
        Merges identity risk summaries and materialized attack paths into a small, sorted
        queue suitable for dashboard display and drill-in routing. This command is read-only.
    .PARAMETER Limit
        Maximum number of queue items to return.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter()]
        [ValidateRange(1, 100)]
        [int]$Limit = 10
    )

    $ErrorActionPreference = 'Stop'

    $items = @()

    foreach ($summary in @(Get-CIEMIdentityRiskSummary | Where-Object { $_.RiskLevel -ne 'Low' })) {
        $signals = Get-CIEMIdentityRiskSignals -PrincipalId ([string]$summary.Id)
        $primarySignal = @(GetCIEMDashboardPrimaryRiskSignal -RiskSignals @($signals.RiskSignals))
        $selectedSignal = if ($primarySignal.Count -gt 0) { $primarySignal[0] } else { $null }
        $signalName = if ($selectedSignal) { [string]$selectedSignal.Signal } else { 'privileged-standing-access' }
        $target = GetCIEMDashboardIdentityTarget -RoleAssignments @($signals.RoleAssignments) -HostingResource $signals.HostingResource
        $reason = GetCIEMDashboardIdentityReason -Summary $summary -PrimarySignal $selectedSignal
        $evidence = GetCIEMDashboardIdentityEvidence -Summary $summary -Target ([string]$target.Name)

        $items += [PSCustomObject]@{
            Id           = "identity:$($summary.Id)"
            SourceType   = 'Identity'
            Severity     = [string]$summary.RiskLevel
            SeverityRank = GetCIEMDashboardSeverityRank -Severity ([string]$summary.RiskLevel)
            SignalRank   = GetCIEMDashboardSignalRank -Signal $signalName
            Title        = [string]$summary.DisplayName
            Identity     = [string]$summary.DisplayName
            IdentityId   = [string]$summary.Id
            IdentityType = [string]$summary.PrincipalType
            TargetId     = [string]$target.Id
            Target       = [string]$target.Name
            Reason       = $reason
            Evidence     = $evidence
            DrillInUrl   = '/ciem/identities'
        }
    }

    foreach ($attackPath in @(Get-CIEMAttackPath)) {
        $severity = ConvertToCIEMDashboardSeverityLabel -Severity ([string]$attackPath.Severity)
        $target = GetCIEMDashboardAttackPathTarget -AttackPath $attackPath
        $identity = GetCIEMDashboardAttackPathIdentityMetadata -AttackPath $attackPath
        $drillInUrl = "/ciem/attack-paths?attackPathId=$([uri]::EscapeDataString([string]$attackPath.Id))"

        $items += [PSCustomObject]@{
            Id           = "attack-path:$($attackPath.Id)"
            SourceType   = 'AttackPath'
            Severity     = $severity
            SeverityRank = GetCIEMDashboardSeverityRank -Severity $severity
            SignalRank   = GetCIEMDashboardSignalRank -Signal 'attack-path'
            Title        = [string]$attackPath.PatternName
            Identity     = [string]$identity.Name
            IdentityId   = [string]$identity.Id
            IdentityType = [string]$identity.Type
            TargetId     = [string]$target.Id
            Target       = [string]$target.Name
            Reason       = "Attack path exposes $($target.Name)"
            Evidence     = [string]$attackPath.PathChain
            DrillInUrl   = $drillInUrl
        }
    }

    @($items |
        Sort-Object `
            @{ Expression = { [int]$_.SeverityRank } }, `
            @{ Expression = { [int]$_.SignalRank } }, `
            @{ Expression = { [string]$_.Title } } |
        Select-Object -First $Limit)
}