Private/ConvertTo-AACServicePrincipalInsight.ps1

function ConvertTo-AACServicePrincipalInsight {
    <#
    .SYNOPSIS
        Builds Get-AACServicePrincipalInsight's list
        (AAC.ServicePrincipalInsight): every service principal and managed
        identity that holds an Azure role - what it is, what it can do,
        what it is attached to, and its credentials.
    .DESCRIPTION
        No Azure calls.
          -Read Invoke-AACGraphBatch's result: roleAssignments
                        (service principals'), roleDefinitions,
                        userIdentities, attached (resources with
                        user-assigned identities)
          -Directory Get-AACDirectoryObject's result for the principals
                        (with appOwnerOrganizationId, alternativeNames,
                        passwordCredentials, keyCredentials), or $null
          -Application the app registrations (appId -> the application:
                        displayName, passwordCredentials, keyCredentials)
        Each principal: Kind - Application, Application (external), Managed
        identity (system-assigned, with its resource type), Managed identity
        (user-assigned), Managed identity (policy assignment) - its role
        assignments and privileged roles, the resources a user-assigned
        identity is attached to, its credentials (the app registration's and
        the service principal's) and the next to expire, and findings:
          High another tenant's application with a privileged role
          Medium another tenant's application with any role; an expired
                  credential on an application that still holds roles; a
                  user-assigned identity with roles attached to nothing; one
                  shared by more than -SharedLimit resources with a
                  privileged role
          Low a credential expiring within -ExpiryDays; a secret valid
                  for more than a year
        Returns @{ Principals; Notices; Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Read,

        [hashtable] $Directory,

        [hashtable] $Application = @{},

        [string] $TenantId = '',

        [ValidateRange(1, 365)]
        [int] $ExpiryDays = 30,

        [ValidateRange(1, 1000)]
        [int] $SharedLimit = 10,

        [datetime] $Now = [datetime]::UtcNow
    )

    $get = ${function:Get-AACMember}
    $text = { param($Object, [string] $Path) $v = & $get $Object $Path; if ($null -eq $v) { '' } else { [string]$v } }
    $list = { param($Item) @(if ($Item -is [System.Collections.IEnumerable] -and $Item -isnot [string] -and $Item -isnot [System.Collections.IDictionary]) { $Item } elseif ($null -ne $Item) { , $Item }) | Where-Object { $null -ne $_ -and '' -ne $_ } }
    $rowsOf = { param([string] $Name) @(if ($Read.Rows -and $Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) }
    $date = {
        param([string] $Value)
        $d = [datetime]::MinValue
        if ($Value -and [datetime]::TryParse($Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$d)) { $d } else { $null }
    }
    $notices = [System.Collections.Generic.List[string]]::new()
    foreach ($key in @($Read.Errors.Keys | Sort-Object)) { $notices.Add("The $key couldn't be read: $($Read.Errors[$key] -replace '\s+', ' ')") }
    if ($null -eq $Directory) { $notices.Add('Entra ID was not read: the principals are shown by ID, without their kind or credentials.') }
    elseif ($Directory.Error) { $notices.Add("The principals couldn't be looked up in Entra ID ($($Directory.Error)): they are shown by ID.") }

    $privilegedIds = @('8e3af657-a8ff-443c-a75c-2fe8c4bcb635', '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168', 'b24988ac-6180-42a0-ab88-20f7382dd24c')
    $roleName = @{}
    foreach ($row in (& $rowsOf 'roleDefinitions')) { $roleName[((& $text $row 'id') -split '/')[-1].ToLowerInvariant()] = & $text $row 'roleName' }
    $scopeText = { param([string] $Scope) if ($Scope -eq '/') { 'root' } elseif ($Scope -match '/managementgroups/([^/]+)$') { "management group $($Matches[1])" } elseif ($Scope -match '^/subscriptions/([^/]+)$') { "subscription $($Matches[1])" } elseif ($Scope -match '/resourcegroups/([^/]+)$') { "resource group $($Matches[1])" } else { ($Scope -split '/')[-1] } }

    # Roles per principal.
    $roles = @{}
    foreach ($row in (& $rowsOf 'roleAssignments')) {
        $p = (& $text $row 'principalId').ToLowerInvariant()
        if (-not $p) { continue }
        $guid = ((& $text $row 'roleId') -split '/')[-1].ToLowerInvariant()
        if (-not $roles.Contains($p)) { $roles[$p] = [System.Collections.Generic.List[object]]::new() }
        $roles[$p].Add(@{ Role = $(if ($roleName.Contains($guid)) { $roleName[$guid] } else { $guid }); Scope = (& $text $row 'scope'); Privileged = $privilegedIds -contains $guid })
    }
    # User-assigned identities and what they are attached to.
    $uami = @{}
    foreach ($row in (& $rowsOf 'userIdentities')) { $uami[(& $text $row 'principalId').ToLowerInvariant()] = @{ Id = (& $text $row 'id').ToLowerInvariant(); Name = (& $text $row 'name'); Attached = 0 } }
    $byResourceId = @{}; foreach ($k in $uami.Keys) { $byResourceId[$uami[$k].Id] = $k }
    foreach ($row in (& $rowsOf 'attached')) {
        $bag = & $get $row 'identities'
        if ($bag -is [System.Collections.IDictionary]) { foreach ($k in $bag.Keys) { $key = ([string]$k).ToLowerInvariant(); if ($byResourceId.Contains($key)) { $uami[$byResourceId[$key]].Attached++ } } }
    }

    $rank = (Get-AACSeverityRank).Rank
    $items = [System.Collections.Generic.List[object]]::new()
    foreach ($principalId in @($roles.Keys)) {
        $o = if ($Directory -and -not $Directory.Error -and $Directory.Objects.Contains($principalId)) { $Directory.Objects[$principalId] } else { $null }
        if ($Directory -and -not $Directory.Error -and -not $o) { continue }  # deleted: Get-AACRoleAssignment and Get-AACOrphanedIdentity report those
        $odata = [string]$(if ($o -is [System.Collections.IDictionary] -and $o.Contains('@odata.type')) { $o['@odata.type'] })
        if ($o -and $odata -notlike '*servicePrincipal') { continue }
        $assigned = @($roles[$principalId])
        $privileged = @($assigned | Where-Object Privileged | ForEach-Object { $_.Role } | Select-Object -Unique)
        $kind = 'Service principal'; $resource = ''; $attached = $null; $credentials = @(); $owner = ''
        if ($o) {
            $alt = @(& $list (& $get $o 'alternativeNames'))
            $resource = [string](@($alt | Where-Object { [string]$_ -match '^/subscriptions/|^/providers/' }) | Select-Object -First 1)
            if ((& $text $o 'servicePrincipalType') -eq 'ManagedIdentity') {
                if ($uami.Contains($principalId)) { $kind = 'Managed identity (user-assigned)'; $attached = $uami[$principalId].Attached; $resource = $uami[$principalId].Id }
                elseif ($resource -match '/policyassignments/') { $kind = 'Managed identity (policy assignment)' }
                else { $kind = "Managed identity (system-assigned$(if ($resource -match '/providers/([^/]+/[^/]+)/[^/]+$') { ": $($Matches[1].ToLowerInvariant())" }))" }
            }
            else {
                $owner = & $text $o 'appOwnerOrganizationId'
                $kind = if ($TenantId -and $owner -and $owner -ne $TenantId) { 'Application (external)' } else { 'Application' }
                $app = if ($Application.Contains((& $text $o 'appId').ToLowerInvariant())) { $Application[(& $text $o 'appId').ToLowerInvariant()] } else { $null }
                foreach ($source in @($app, $o)) {
                    if (-not $source) { continue }
                    foreach ($c in @(& $list (& $get $source 'passwordCredentials'))) { $credentials += @{ Kind = 'secret'; Start = (& $date (& $text $c 'startDateTime')); End = (& $date (& $text $c 'endDateTime')) } }
                    foreach ($c in @(& $list (& $get $source 'keyCredentials'))) { $credentials += @{ Kind = 'certificate'; Start = (& $date (& $text $c 'startDateTime')); End = (& $date (& $text $c 'endDateTime')) } }
                }
            }
        }
        $live = @($credentials | Where-Object { $_.End -and $_.End -gt $Now })
        $expired = @($credentials | Where-Object { $_.End -and $_.End -le $Now })
        $next = @($live | Sort-Object { $_.End } | Select-Object -First 1)
        $findings = [System.Collections.Generic.List[object]]::new()
        if ($kind -eq 'Application (external)') {
            if ($privileged.Count) { $findings.Add(@{ S = 'High'; T = "Another tenant's application holds $($privileged -join ', ') - whoever controls that app's credentials has this access. Confirm the vendor needs it, at this scope." }) }
            else { $findings.Add(@{ S = 'Medium'; T = "Another tenant's application holds Azure roles - check the vendor still needs them." }) }
        }
        if ($expired.Count -and -not $live.Count) { $findings.Add(@{ S = 'Medium'; T = "Every credential has expired: whatever uses it fails to sign in - rotate it, or remove the application and its roles." }) }
        if ($next.Count -and ($next[0].End - $Now).TotalDays -le $ExpiryDays) { $findings.Add(@{ S = 'Low'; T = "A $($next[0].Kind) expires in $([int][Math]::Ceiling(($next[0].End - $Now).TotalDays)) day(s) ($($next[0].End.ToString('yyyy-MM-dd'))) - rotate it before then." }) }
        if (@($live | Where-Object { $_.Kind -eq 'secret' -and $_.Start -and ($_.End - $_.Start).TotalDays -gt 366 }).Count) { $findings.Add(@{ S = 'Low'; T = 'A client secret is valid for more than a year: prefer short-lived secrets, a certificate, or a managed identity / workload identity federation.' }) }
        if ($null -ne $attached -and $attached -eq 0) { $findings.Add(@{ S = 'Medium'; T = 'A user-assigned identity with Azure roles, attached to no resource: delete it, or its roles, if nothing will use it.' }) }
        if ($null -ne $attached -and $attached -gt $SharedLimit -and $privileged.Count) { $findings.Add(@{ S = 'Medium'; T = "Shared by $attached resources with $($privileged -join ', '): each of them acts with that access - split it per workload." }) }
        $worst = @($findings | Sort-Object { $rank[$_.S] } | Select-Object -First 1)
        $item = [pscustomobject][ordered]@{
            Severity          = $(if ($worst.Count) { $worst[0].S } else { 'Info' })
            ServicePrincipal  = $(if ($o -and (& $text $o 'displayName')) { & $text $o 'displayName' } else { $principalId })
            Kind              = $kind
            RoleAssignments   = $assigned.Count
            PrivilegedRoles   = ($privileged -join ', ')
            Roles             = (@($assigned | Sort-Object { -not $_.Privileged } | Select-Object -First 5 | ForEach-Object { "$($_.Role) on $(& $scopeText $_.Scope)" }) -join '; ') + $(if ($assigned.Count -gt 5) { "; +$($assigned.Count - 5) more" } else { '' })
            AttachedResources = $attached
            Credentials       = $(if ($credentials.Count) { "$(@($credentials | Where-Object Kind -EQ 'secret').Count) secret(s), $(@($credentials | Where-Object Kind -EQ 'certificate').Count) certificate(s)$(if ($expired.Count) { ", $($expired.Count) expired" })" } else { '' })
            NextExpiry        = $(if ($next.Count) { $next[0].End } else { $null })
            Findings          = (@($findings | ForEach-Object { $_.T }) -join ' ')
            OwnerTenant       = $owner
            AppId             = $(if ($o) { & $text $o 'appId' } else { '' })
            PrincipalId       = $principalId
            ResourceId        = $resource
        }
        $item.PSObject.TypeNames.Insert(0, 'AAC.ServicePrincipalInsight')
        $items.Add($item)
    }
    $sorted = @($items | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, @{ Expression = 'RoleAssignments'; Descending = $true }, ServicePrincipal)
    @{
        Principals = $sorted
        Notices    = @($notices)
        Stats      = @{
            Principals = $sorted.Count
            External   = @($sorted | Where-Object Kind -EQ 'Application (external)').Count
            Managed    = @($sorted | Where-Object Kind -Like 'Managed identity*').Count
            Expiring   = @($sorted | Where-Object { $_.NextExpiry -and ($_.NextExpiry - $Now).TotalDays -le $ExpiryDays }).Count
            High       = @($sorted | Where-Object Severity -EQ 'High').Count
        }
    }
}