Private/ConvertTo-AACOrphanedIdentity.ps1
|
function ConvertTo-AACOrphanedIdentity { <# .SYNOPSIS Builds Get-AACOrphanedIdentity's list (AAC.OrphanedIdentity) from the Resource Graph rows and Microsoft Graph answers of Get-AACOrphanedIdentityQuery: identities nobody uses any more - and the access they still hold. .DESCRIPTION No Azure calls. -Read is Invoke-AACGraphBatch's result; -Federated maps a user-assigned identity's ID (lower case) to its number of federated credentials ($null: not read); -Directory is Get-AACDirectoryObject's result for the role assignments' principals ($null: not read); -Entra is Read-AACGraphQuery's ($null: not read). Role assignments count when their scope is a subscription in -SubscriptionName (or below it), a management group or the root. Deleted principal role assignments to a principal Entra ID no longer has ("Identity not found") User-assigned identity attached to no resource and no federated credential (not used by any workload) Managed identity the resource it belonged to is gone Application the tenant's service principal not signed in for -InactiveDays (or ever), disabled but still assigned roles; its app registration with only expired credentials, or no owners Severity follows the access left behind: High when it holds a privileged role (Owner, Contributor, User Access Administrator, Role Based Access Control Administrator), Medium with other roles (or only expired credentials), Low with none. Returns @{ Identities; Notices; Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [hashtable] $Read, [hashtable] $Federated = @{}, [hashtable] $Directory, [hashtable] $Entra, [string] $TenantId = '', [ValidateRange(1, 3650)] [int] $InactiveDays = 90, [hashtable] $SubscriptionName = @{}, [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 $_ } }) } $readOk = { param([string] $Name) $Read.Rows -and $Read.Rows.Contains($Name) -and -not ($Read.Errors -and $Read.Errors.Contains($Name)) } $subscriptionOf = { param([string] $Id) if ($Id -match '^/subscriptions/([^/]+)') { $Matches[1].ToLowerInvariant() } else { '' } } $label = { param([string] $Id) $s = ([string]$Id).ToLowerInvariant(); if ($SubscriptionName.Contains($s)) { $SubscriptionName[$s] } else { $Id } } $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+', ' ')") } # --- Role assignments in scope, by principal ------------------------------------------------------------------ $privileged = @('8e3af657-a8ff-443c-a75c-2fe8c4bcb635', 'b24988ac-6180-42a0-ab88-20f7382dd24c', '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168') $roleName = @{} foreach ($row in (& $rowsOf 'roleDefinitions')) { $roleName[((& $text $row 'id') -split '/')[-1]] = & $text $row 'roleName' } $assigned = @{} foreach ($row in (& $rowsOf 'roleAssignments')) { $scope = & $text $row 'scope' $sub = & $subscriptionOf $scope if ($sub -and -not $SubscriptionName.Contains($sub)) { continue } $principal = (& $text $row 'principalId').ToLowerInvariant() if (-not $principal) { continue } $guid = ((& $text $row 'roleId') -split '/')[-1] if (-not $assigned.Contains($principal)) { $assigned[$principal] = [System.Collections.Generic.List[object]]::new() } $assigned[$principal].Add(@{ Role = $(if ($roleName.Contains($guid)) { $roleName[$guid] } else { $guid }); Scope = $scope; Privileged = $privileged -contains $guid; Type = (& $text $row 'principalType') }) } $access = { param([string] $PrincipalId) $a = @(if ($PrincipalId -and $assigned.Contains($PrincipalId.ToLowerInvariant())) { $assigned[$PrincipalId.ToLowerInvariant()] }) $scopeText = { param([string] $Scope) if ($Scope -match '/managementgroups/([^/]+)$') { "management group $($Matches[1])" } elseif ($Scope -eq '/') { 'root' } elseif ($Scope -match '^/subscriptions/([^/]+)$') { "subscription $(& $label $Matches[1])" } else { ($Scope -split '/')[-1] } } @{ Count = $a.Count; Privileged = @($a | Where-Object { $_.Privileged }).Count -gt 0; Text = (@($a | Sort-Object { -not $_.Privileged } | Select-Object -First 5 | ForEach-Object { "$($_.Role) on $(& $scopeText $_.Scope)" }) -join '; ') + $(if ($a.Count -gt 5) { "; +$($a.Count - 5) more" } else { '' }) } } $severityFor = { param($Access, [string] $Floor = 'Low') if ($Access.Privileged) { 'High' } elseif ($Access.Count -or $Floor -eq 'Medium') { 'Medium' } else { 'Low' } } $identities = [System.Collections.Generic.List[object]]::new() $add = { param([string] $Severity, [string] $Kind, [string] $Identity, [string[]] $Reasons, $Access, [string] $Remediation, [hashtable] $More = @{}) $item = [pscustomobject][ordered]@{ Severity = $Severity Kind = $Kind Identity = $Identity Reason = ($Reasons -join ' ') LastSignIn = $More['LastSignIn'] DaysInactive = $(if ($More['LastSignIn']) { [int][Math]::Floor(($Now - $More['LastSignIn']).TotalDays) } else { $null }) RoleAssignments = $Access.Count Roles = $Access.Text Credentials = [string]$More['Credentials'] Owners = $More['Owners'] Created = $More['Created'] Remediation = $Remediation AppId = [string]$More['AppId'] PrincipalId = [string]$More['PrincipalId'] ResourceGroup = [string]$More['ResourceGroup'] Subscription = [string]$More['Subscription'] ResourceId = [string]$More['ResourceId'] Id = [string]$More['Id'] } $item.PSObject.TypeNames.Insert(0, 'AAC.OrphanedIdentity') $identities.Add($item) } # --- Role assignments to deleted principals -------------------------------------------------------------------------- if ($null -ne $Directory) { if ($Directory.Error) { $notices.Add("The role assignments' principals couldn't be looked up in Entra ID: $($Directory.Error)") } else { foreach ($principal in @($assigned.Keys | Sort-Object)) { if ($Directory.Objects.Contains($principal)) { continue } $a = & $access $principal $type = @($assigned[$principal] | ForEach-Object { $_.Type } | Where-Object { $_ } | Select-Object -Unique) -join ', ' & $add (& $severityFor $a 'Medium') 'Deleted principal' "Identity not found ($(if ($type) { $type } else { 'unknown type' }))" @('The principal no longer exists in Entra ID, but its role assignments remain - shown as "Identity not found" in the portal.') $a 'Remove its role assignments (they grant nothing, but they clutter every access review and hide real ones).' @{ PrincipalId = $principal } } } } # --- User-assigned identities nothing uses -------------------------------------------------------------------------- $used = @{} foreach ($row in (& $rowsOf 'attached')) { $bag = & $get $row 'identities' if ($bag -is [System.Collections.IDictionary]) { foreach ($k in $bag.Keys) { $used[([string]$k).ToLowerInvariant()] = $true } } $kubelet = & $text $row 'kubelet' if ($kubelet) { $used[$kubelet] = $true } } if (& $readOk 'attached') { foreach ($row in (& $rowsOf 'userIdentities')) { $sub = (& $text $row 'subscriptionId').ToLowerInvariant() if (-not $SubscriptionName.Contains($sub)) { continue } $id = & $text $row 'id' if ($used.Contains($id)) { continue } $credentialCount = if ($Federated.Contains($id)) { $Federated[$id] } else { $null } if ($credentialCount -gt 0) { continue } $a = & $access (& $text $row 'principalId') & $add (& $severityFor $a) 'User-assigned identity' (& $text $row 'name') @("Attached to no resource$(if ($null -eq $credentialCount) { ' (federated credentials not checked)' } else { ' and with no federated credential' }).") $a $(if ($a.Count) { 'Delete it (and its role assignments) if nothing will use it - anyone allowed to assign it to a resource gets its access.' } else { 'Delete it if nothing will use it.' }) @{ PrincipalId = (& $text $row 'principalId'); AppId = (& $text $row 'clientId'); ResourceGroup = (& $text $row 'resourceGroup'); Subscription = (& $label $sub); ResourceId = $id } } } # --- Entra ID: managed identities and applications ------------------------------------------------------------------ if ($null -ne $Entra) { $data = if ($Entra.Contains('Data')) { $Entra.Data } else { @{} } $failed = if ($Entra.Contains('Errors')) { $Entra.Errors } else { @{} } foreach ($key in @($failed.Keys | Sort-Object)) { $notices.Add("$(@{ servicePrincipals = 'The service principals'; managedIdentities = 'The managed identities'; applications = 'The app registrations'; signIns = 'The service principals'' sign-in activity (it needs AuditLog.Read.All and Entra ID P1)' }[$key]) couldn't be read: $($failed[$key] -replace '\s+', ' ')") } # Managed identities whose resource is gone. if ((& $readOk 'systemIdentities') -and (& $readOk 'userIdentities')) { $exists = @{} foreach ($row in (& $rowsOf 'systemIdentities')) { $exists[(& $text $row 'id')] = $true } foreach ($row in (& $rowsOf 'userIdentities')) { $exists[(& $text $row 'id')] = $true } foreach ($mi in @(& $list $data['managedIdentities'])) { $resourceId = [string](@(& $list (& $get $mi 'alternativeNames')) | Where-Object { $_ -match '^/subscriptions/' } | Select-Object -First 1) $sub = & $subscriptionOf $resourceId if (-not $resourceId -or -not $SubscriptionName.Contains($sub) -or $exists.Contains($resourceId.ToLowerInvariant())) { continue } $a = & $access (& $text $mi 'id') & $add (& $severityFor $a) 'Managed identity' (& $text $mi 'displayName') @("The resource it belonged to ($(($resourceId -split '/')[-1])) no longer exists.") $a 'Remove its role assignments; Entra ID deletes the service principal of a deleted resource''s identity (open a support case if it stays).' @{ PrincipalId = (& $text $mi 'id'); AppId = (& $text $mi 'appId'); Subscription = (& $label $sub); ResourceId = $resourceId.ToLowerInvariant(); Id = (& $text $mi 'id') } } } # Applications: the tenant's service principals and app registrations. $signInsRead = $data.Contains('signIns') -and -not $failed.Contains('signIns') $lastOf = @{} foreach ($s in @(& $list $data['signIns'])) { $lastOf[(& $text $s 'appId').ToLowerInvariant()] = & $date (& $text $s 'lastSignInActivity.lastSignInDateTime') } $apps = @{} foreach ($app in @(& $list $data['applications'])) { $apps[(& $text $app 'appId').ToLowerInvariant()] = $app } $sps = @{} foreach ($sp in @(& $list $data['servicePrincipals'])) { if (-not $TenantId -or (& $text $sp 'appOwnerOrganizationId') -eq $TenantId) { $sps[(& $text $sp 'appId').ToLowerInvariant()] = $sp } } foreach ($appId in @(@($apps.Keys) + @($sps.Keys) | Select-Object -Unique | Sort-Object)) { $app = $apps[$appId]; $sp = $sps[$appId] $reasons = [System.Collections.Generic.List[string]]::new() $floor = 'Low' $a = if ($sp) { & $access (& $text $sp 'id') } else { @{ Count = 0; Privileged = $false; Text = '' } } $created = & $date (& $text $(if ($app) { $app } else { $sp }) 'createdDateTime') $last = if ($lastOf.Contains($appId)) { $lastOf[$appId] } else { $null } if ($sp -and $signInsRead -and (-not $created -or ($Now - $created).TotalDays -ge $InactiveDays)) { if (-not $last) { $reasons.Add("No sign-in recorded$(if ($created) { " since it was created ($($created.ToString('yyyy-MM-dd')))" }).") } elseif (($Now - $last).TotalDays -ge $InactiveDays) { $reasons.Add("No sign-in for $([int][Math]::Floor(($Now - $last).TotalDays)) days (last $($last.ToString('yyyy-MM-dd'))).") } } if ($sp -and (& $text $sp 'accountEnabled') -eq 'False' -and $a.Count) { $reasons.Add('Disabled, but still assigned roles.') } $credentials = @(@(& $list (& $get $app 'passwordCredentials')) + @(& $list (& $get $app 'keyCredentials'))) $credentialText = '' if ($app) { $secrets = @(& $list (& $get $app 'passwordCredentials')).Count; $certs = @(& $list (& $get $app 'keyCredentials')).Count $credentialText = "$secrets secret(s), $certs certificate(s)" $ends = @($credentials | ForEach-Object { & $date (& $text $_ 'endDateTime') } | Where-Object { $_ }) if ($ends.Count -and -not @($ends | Where-Object { $_ -gt $Now }).Count) { $reasons.Add("Every credential has expired (the last on $((@($ends | Sort-Object))[-1].ToString('yyyy-MM-dd'))): nothing can sign in as it."); $floor = 'Medium'; $credentialText += ', all expired' } if (-not @(& $list (& $get $app 'owners')).Count) { $reasons.Add('No owners: nobody is accountable for it.') } } if (-not $reasons.Count) { continue } & $add (& $severityFor $a $floor) 'Application' (& $text $(if ($app) { $app } else { $sp }) 'displayName') $reasons.ToArray() $a $(if ($a.Count) { 'Confirm with its owner it is unused; remove its role assignments, disable the service principal for a while, then delete the app registration.' } else { 'Confirm it is unused, then delete the app registration (or assign owners if it is still needed).' }) @{ LastSignIn = $last; Credentials = $credentialText; Owners = $(if ($app) { @(& $list (& $get $app 'owners')).Count } else { $null }); Created = $created; AppId = $appId; PrincipalId = $(if ($sp) { & $text $sp 'id' } else { '' }); Id = $(if ($app) { & $text $app 'id' } else { & $text $sp 'id' }) } } if (-not $signInsRead -and $data.Contains('servicePrincipals')) { $notices.Add('Without sign-in activity, inactive service principals can''t be told apart: only expired credentials, missing owners and disabled ones are listed.') } } $rank = (Get-AACSeverityRank).Rank $sorted = @($identities | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, @{ Expression = 'RoleAssignments'; Descending = $true }, Kind, Identity) $assignmentTotal = 0; foreach ($i in $sorted) { $assignmentTotal += [int]$i.RoleAssignments } @{ Identities = $sorted Notices = @($notices) Stats = @{ Identities = $sorted.Count High = @($sorted | Where-Object Severity -EQ 'High').Count Deleted = @($sorted | Where-Object Kind -EQ 'Deleted principal').Count Unattached = @($sorted | Where-Object Kind -EQ 'User-assigned identity').Count Applications = @($sorted | Where-Object Kind -EQ 'Application').Count WithAccess = @($sorted | Where-Object RoleAssignments -GT 0).Count Assignments = $assignmentTotal } } } |