src/collect/Resolve-ScoutOrphanedRoleAssignment.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Flag role assignments whose principal no longer exists in Entra ID -- "who has Owner" answers who is CURRENTLY assigned; this answers whether that assignment still points at anyone. .DESCRIPTION Story AB#6456 (Feature AB#6455, Epic AB#6454). The Role Assignments worksheet (manifests/collectors/Identity/RoleAssignments.psd1, shipped by Epic AB#6731) already renders every assignment's Principal ID and Principal Type. What it cannot say is whether that principal still resolves -- a role assignment left behind after a user is deleted, a service principal is removed, or a group is disbanded is a standing grant nobody can revoke by deleting the object anymore, and it is exactly the kind of finding a governance review exists to surface. This is a PURE, LOCAL transform. It makes no Azure Resource Graph or Microsoft Graph call of its own -- it reads two things Scout has already collected in the same run: * the 'AZSC/Governance/RoleAssignment' envelope ConvertTo-ScoutGovernanceResource produced from the ARM role-assignment sweep (Get-ScoutGovernanceDataset), and * the 'entra/users' / 'entra/groups' / 'entra/serviceprincipals' / 'entra/managedidentities' rows Start-AZSCEntraExtraction produced from Microsoft Graph. Both already live in the same merged $Resources array by the time this runs (Start- AZSCExtractionOrchestration merges Entra resources into $Resources right before calling this). That is the "collect once" rule the audit set out (AB#6779 and its family): resolving a principal ID against data already in memory costs nothing, so a second Graph round trip to look each one up individually is never justified. THE PERMISSION TRAP THIS EXISTS TO AVOID (see the audit, section 9): Azure RBAC and Microsoft Graph are two separate permission systems. A caller can have full Reader access to every subscription and management group and have ZERO Graph permissions -- Scout's own pre-flight (Get-ScoutGraphPermissionImpact) exists because that combination is common. If Graph access was denied, Start-AZSCEntraExtraction still emits an empty 'entra/users' row set -- indistinguishable, by row count alone, from a tenant that genuinely has no users. Reporting every User-typed assignment as orphaned in that case would manufacture a false, and alarming, security finding. QueryOutcomes (Start-AZSCEntraExtraction's other output field, added alongside this story) is what breaks the tie: it names, per Graph query, whether the query actually SUCCEEDED, independent of how many rows it returned. A principal whose backing query never succeeded is reported 'NotAssessed', never 'Orphaned'. .PARAMETER Resources The full, already-merged resource set for this run -- ARM rows, the governance envelope, and (when Entra extraction ran) the entra/* principal rows. Returned unchanged in shape; only the 'AZSC/Governance/RoleAssignment' envelope's `.properties` rows gain two new columns. .PARAMETER EntraQueryOutcomes Start-AZSCEntraExtraction's `QueryOutcomes` output (an array of @{ Type; Name; Success; Count }). Optional and defaults to empty, which means "no Entra query is known to have succeeded" -- the same conservative outcome as an ArmOnly run, a run with no -TenantID, or an Entra extraction result from before this story existed. Every role assignment then reports 'NotAssessed' rather than guessing. .OUTPUTS The SAME $Resources array (returned for convenient chaining), with the 'AZSC/Governance/ RoleAssignment' envelope's rows carrying two additional fields: 'Principal Resolution' -- 'Resolved' | 'Orphaned' | 'NotAssessed' 'Principal Display Name' -- the resolved object's display/user-principal name, or $null .NOTES Tracks ADO Story AB#6456 (Feature AB#6455, Epic AB#6454). Entra GROUP resolution is a first-class case here (Feature AB#6455's own acceptance criterion): a Group-typed assignment resolves against 'entra/groups' exactly like a User resolves against 'entra/users'. Whether the resolved group HAS members is deliberately left unanswered -- Scout does not collect group membership today (the Graph 'list groups' endpoint only supports `$expand=members` for small groups, is not paginable, and adding it would be a NEW Graph call this story's own "resolve locally, no new calls" constraint rules out). That is a real gap, not an oversight, and it is called out rather than faked with a $null-filled column of a name nobody asked for. ForeignGroup and Device are deliberately NOT resolved against anything: a ForeignGroup is a group that lives in a DIFFERENT tenant (cross-tenant/B2B collaboration) and by definition will never appear in this tenant's Groups query, so treating a lookup miss as "orphaned" would manufacture a false finding against a legitimate assignment. Both report 'NotAssessed'. #> function Resolve-ScoutOrphanedRoleAssignment { [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Resources, [Parameter()] [AllowEmptyCollection()] [object[]] $EntraQueryOutcomes = @() ) Set-StrictMode -Version Latest # StrictMode-safe, case-insensitive property read. Entra rows carry an uppercase 'TYPE' # (Start-AZSCEntraExtraction's Add-NormalizedResource), the governance envelope and ARM rows # carry a lowercase 'type' -- a plain `.type` read would silently miss half of $Resources. function Get-ScoutOrphanValue { param([object] $InputObject, [Parameter(Mandatory)][string] $Name) if ($null -eq $InputObject) { return $null } $property = $InputObject.PSObject.Properties | Where-Object { $_.Name -ieq $Name } | Select-Object -First 1 if ($null -eq $property) { return $null } return $property.Value } # ---- ARM principalType -> the synthetic Entra type(s) that can resolve it ------------------ # ARM's own vocabulary (properties.principalType) is exactly: User, Group, ServicePrincipal, # ForeignGroup, Device, Unknown. Only the first three have a local dataset to check against; # the rest fall through to 'NotAssessed' below, never 'Orphaned'. $typeMap = @{ 'user' = @('entra/users') 'group' = @('entra/groups') # A managed identity IS a service principal (servicePrincipalType eq 'ManagedIdentity' # under the same /servicePrincipals endpoint), so 'entra/managedidentities' is already a # subset of 'entra/serviceprincipals'. Both are checked so a caller whose only success was # the (rarer) filtered managed-identity query still resolves. 'serviceprincipal' = @('entra/serviceprincipals', 'entra/managedidentities') } # ---- which Entra queries are known to have actually succeeded ------------------------------ $succeededTypes = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($outcome in @($EntraQueryOutcomes)) { if ($null -eq $outcome) { continue } $type = [string](Get-ScoutOrphanValue $outcome 'Type') $success = Get-ScoutOrphanValue $outcome 'Success' if (-not [string]::IsNullOrWhiteSpace($type) -and $success -eq $true) { [void]$succeededTypes.Add($type) } } function Test-ScoutEntraTypeCollected { param([string[]] $Types) foreach ($candidate in $Types) { if ($succeededTypes.Contains($candidate)) { return $true } } return $false } # ---- id -> display name, one lookup table per synthetic Entra type ------------------------- $entraByType = @{} foreach ($resource in @($Resources)) { if ($null -eq $resource) { continue } $type = [string](Get-ScoutOrphanValue $resource 'TYPE') if ([string]::IsNullOrWhiteSpace($type)) { $type = [string](Get-ScoutOrphanValue $resource 'type') } if ([string]::IsNullOrWhiteSpace($type) -or $type -notlike 'entra/*') { continue } $id = [string](Get-ScoutOrphanValue $resource 'id') if ([string]::IsNullOrWhiteSpace($id)) { continue } $key = $type.ToLowerInvariant() if (-not $entraByType.ContainsKey($key)) { $entraByType[$key] = @{} } $entraByType[$key][$id.ToLowerInvariant()] = [string](Get-ScoutOrphanValue $resource 'name') } function Resolve-ScoutPrincipal { param([string] $PrincipalId, [string] $PrincipalType) if ([string]::IsNullOrWhiteSpace($PrincipalId)) { return [pscustomobject]@{ Resolution = 'NotAssessed'; DisplayName = $null } } $normalizedType = if ($PrincipalType) { $PrincipalType.Trim().ToLowerInvariant() } else { '' } if (-not $typeMap.ContainsKey($normalizedType)) { # ForeignGroup / Device / Unknown / absent -- see .NOTES. This collector cannot answer # for these, and "cannot answer" must never render as "orphaned". return [pscustomobject]@{ Resolution = 'NotAssessed'; DisplayName = $null } } $entraTypes = @($typeMap[$normalizedType]) if (-not (Test-ScoutEntraTypeCollected -Types $entraTypes)) { return [pscustomobject]@{ Resolution = 'NotAssessed'; DisplayName = $null } } $idKey = $PrincipalId.ToLowerInvariant() foreach ($entraType in $entraTypes) { if ($entraByType.ContainsKey($entraType) -and $entraByType[$entraType].ContainsKey($idKey)) { return [pscustomobject]@{ Resolution = 'Resolved'; DisplayName = $entraByType[$entraType][$idKey] } } } return [pscustomobject]@{ Resolution = 'Orphaned'; DisplayName = $null } } foreach ($envelope in @($Resources)) { if ($null -eq $envelope) { continue } $envelopeType = [string](Get-ScoutOrphanValue $envelope 'type') if ($envelopeType -ine 'AZSC/Governance/RoleAssignment') { continue } $rows = @(Get-ScoutOrphanValue $envelope 'properties') $enrichedRows = @( foreach ($row in $rows) { if ($null -eq $row) { continue } $principalId = [string](Get-ScoutOrphanValue $row 'Principal ID') $principalType = [string](Get-ScoutOrphanValue $row 'Principal Type') $result = Resolve-ScoutPrincipal -PrincipalId $principalId -PrincipalType $principalType # A fresh object rather than an in-place Add-Member on $row: $row is a row # ConvertTo-ScoutGovernanceResource built earlier in the SAME pass, and nothing # else holds a reference to it yet, but this keeps the function's own contract -- # "reads $Resources, does not mutate objects found inside it" -- true even if a # future caller re-runs this over rows it is still holding elsewhere. $clone = [PSCustomObject]@{} foreach ($property in $row.PSObject.Properties) { $clone | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value } $clone | Add-Member -NotePropertyName 'Principal Resolution' -NotePropertyValue $result.Resolution $clone | Add-Member -NotePropertyName 'Principal Display Name' -NotePropertyValue $result.DisplayName $clone } ) # The envelope itself IS mutated -- that is the point. It is the same object the # RoleAssignments collector reads out of $Resources by reference, so replacing its # `.properties` here is what makes the new columns reach the worksheet without a second # collection pass or a second copy of $Resources. $envelope.properties = $enrichedRows } return $Resources } |