Public/Get-EGIGroupBlastRadius.ps1
|
function Get-EGIGroupBlastRadius { <# .SYNOPSIS Maps everything downstream that depends on a given Entra ID group. .DESCRIPTION A dynamic group's rule usually looks harmless in isolation, but the group itself is frequently the target of Conditional Access policies, license assignment, application role assignments, and (for role-assignable groups) PIM eligibility. This function collects all of those references so a membership-rule change can be evaluated against its real blast radius before it is saved. Nested group membership is included on both sides: - Parent groups (this group's transitive memberOf) are checked for the same Conditional Access / license / app role dependencies, since Entra evaluates group-based Conditional Access, group-based licensing, and group-based app role assignment against a user's *transitive* group membership - a policy on a parent group reaches this group's members even though it never references this group's ID directly. Each such hit is tagged with which group it actually came from (Source: 'Direct' or "Nested via '<parent>'"). - Child groups (other groups nested as this group's transitive members) are listed for visibility into the nesting structure, but their own dependencies are not expanded - a child group's assignments don't flow back up to this group's members. - Dynamic groups can also nest by rule instead of by structure: a membership rule using the (preview) memberOf operator, e.g. 'user.memberof -any (group.objectId -in [''<id>''])', makes this group's effective population mirror another group's membership without this group ever being added as that group's member. Any group referenced this way is resolved and listed in RuleReferencedGroups for visibility - same as ChildGroups, these are not expanded for their own Conditional Access / license / app role dependencies, since a referenced group's own dependencies already apply to its members directly and don't need to be duplicated onto this group's blast radius. - The reverse of the point above also matters: some *other* dynamic group elsewhere in the tenant might reference *this* group through its own memberOf clause, so a change to this group's population would ripple into that other group too. Since Graph has no "who references me" query, this is found by listing every dynamic group in the tenant and scanning each one's rule text for a memberOf reference to this group's ID; matches are listed in RuleReferencedByGroups. This is the one part of the nested-group detection that costs an extra tenant-wide group listing call, so it scales with how many dynamic groups the tenant has. Requires an existing Microsoft Graph connection (Connect-MgGraph) with at least: Group.Read.All, Policy.Read.All, Directory.Read.All, and RoleManagement.Read.Directory if the group is role-assignable. .PARAMETER GroupId Object ID of the group to analyze. .EXAMPLE Get-EGIGroupBlastRadius -GroupId '11111111-2222-3333-4444-555555555555' | Format-List #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string] $GroupId ) $group = Invoke-MgGraphRequest -Method GET ` -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId`?`$select=id,displayName,groupTypes,membershipRule,membershipRuleProcessingState,assignedLicenses,isAssignableToRole,resourceProvisioningOptions" Write-Verbose "Analyzing blast radius for group '$($group.displayName)' ($GroupId)" # --- Nesting: parent groups (this group's transitive memberOf) ------ $parentGroups = @() try { $parentGroups = Invoke-EGIGraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/transitiveMemberOf/microsoft.graph.group?`$select=id,displayName,groupTypes" } catch { Write-Warning "Could not read parent group memberships for group '$($group.displayName)': $($_.Exception.Message)" } # --- Nesting: child groups (other groups nested inside this one) ---- $childGroups = @() try { $childGroups = Invoke-EGIGraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/transitiveMembers/microsoft.graph.group?`$select=id,displayName,groupTypes" } catch { Write-Warning "Could not read nested member groups for group '$($group.displayName)': $($_.Exception.Message)" } # --- Nesting: groups referenced by a memberOf (preview) rule clause -- # e.g. 'user.memberof -any (group.objectId -in [''<id>''])'. Scoped to # the text right after the word 'memberof' up to its first closing # paren, so GUIDs elsewhere in an unrelated part of the rule aren't # picked up by mistake. $ruleReferencedGroups = [System.Collections.Generic.List[pscustomobject]]::new() if (-not [string]::IsNullOrWhiteSpace($group.membershipRule)) { $refIds = [System.Collections.Generic.List[string]]::new() foreach ($clause in [regex]::Matches($group.membershipRule, '(?i)memberof[^()]*\(([^)]*)\)')) { foreach ($idMatch in [regex]::Matches($clause.Groups[1].Value, '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}')) { if ($refIds -notcontains $idMatch.Value) { $refIds.Add($idMatch.Value) } } } foreach ($refId in $refIds) { try { $refGroup = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$refId`?`$select=id,displayName" $ruleReferencedGroups.Add([pscustomobject]@{ id = $refGroup.id; displayName = $refGroup.displayName }) } catch { Write-Warning "Could not resolve group '$refId' referenced by '$($group.displayName)''s membershipRule (memberOf): $($_.Exception.Message)" $ruleReferencedGroups.Add([pscustomobject]@{ id = $refId; displayName = "(unresolved: $refId)" }) } } } # --- Nesting: other dynamic groups whose own rule references this ---- # --- group via memberOf (the reverse of RuleReferencedGroups) -------- $ruleReferencedByGroups = [System.Collections.Generic.List[pscustomobject]]::new() try { $allDynamicGroups = Invoke-EGIGraphPaged -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=groupTypes/any(c:c eq 'DynamicMembership')&`$select=id,displayName,membershipRule" foreach ($candidate in $allDynamicGroups) { if ($candidate.id -eq $GroupId) { continue } if ([string]::IsNullOrWhiteSpace($candidate.membershipRule)) { continue } $referencesThisGroup = $false foreach ($clause in [regex]::Matches($candidate.membershipRule, '(?i)memberof[^()]*\(([^)]*)\)')) { if ([regex]::IsMatch($clause.Groups[1].Value, [regex]::Escape($GroupId), 'IgnoreCase')) { $referencesThisGroup = $true break } } if ($referencesThisGroup) { $ruleReferencedByGroups.Add([pscustomobject]@{ id = $candidate.id; displayName = $candidate.displayName }) } } } catch { Write-Warning "Could not scan tenant dynamic groups for memberOf references to group '$($group.displayName)': $($_.Exception.Message)" } # --- Conditional Access policies referencing this group, direct or --- # --- inherited through a parent group's include/exclude list -------- $caPolicies = Invoke-EGIGraphPaged -Uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies?$select=id,displayName,state,conditions' $caMatches = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($policy in $caPolicies) { $includeHit = $policy.conditions.users.includeGroups -contains $GroupId $excludeHit = $policy.conditions.users.excludeGroups -contains $GroupId if ($includeHit -or $excludeHit) { $caMatches.Add([pscustomobject]@{ DisplayName = $policy.displayName State = $policy.state Reference = if ($includeHit -and $excludeHit) { 'Include+Exclude' } elseif ($includeHit) { 'Include' } else { 'Exclude' } Source = 'Direct' }) } foreach ($parent in $parentGroups) { $parentIncludeHit = $policy.conditions.users.includeGroups -contains $parent.id $parentExcludeHit = $policy.conditions.users.excludeGroups -contains $parent.id if ($parentIncludeHit -or $parentExcludeHit) { $caMatches.Add([pscustomobject]@{ DisplayName = $policy.displayName State = $policy.state Reference = if ($parentIncludeHit -and $parentExcludeHit) { 'Include+Exclude' } elseif ($parentIncludeHit) { 'Include' } else { 'Exclude' } Source = "Nested via '$($parent.displayName)'" }) } } } # --- License assignment, direct or inherited through a parent group - # Outer @() keeps a single license from unrolling to a bare hashtable, # whose .Count would be its key count rather than 1. $licenses = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($lic in @($group.assignedLicenses) | Where-Object { $_.skuId }) { $licenses.Add([pscustomobject]@{ SkuId = $lic.skuId; Source = 'Direct' }) } foreach ($parent in $parentGroups) { try { $parentDetails = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$($parent.id)`?`$select=assignedLicenses" foreach ($lic in @($parentDetails.assignedLicenses) | Where-Object { $_.skuId }) { $licenses.Add([pscustomobject]@{ SkuId = $lic.skuId; Source = "Nested via '$($parent.displayName)'" }) } } catch { Write-Warning "Could not read license assignment for parent group '$($parent.displayName)': $($_.Exception.Message)" } } # --- Application role assignments, direct or inherited -------------- $appRoleAssignments = [System.Collections.Generic.List[pscustomobject]]::new() try { foreach ($a in Invoke-EGIGraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/appRoleAssignments") { $appRoleAssignments.Add([pscustomobject]@{ resourceDisplayName = $a.resourceDisplayName; appRoleId = $a.appRoleId; Source = 'Direct' }) } } catch { Write-Warning "Could not read app role assignments for group '$($group.displayName)': $($_.Exception.Message)" } foreach ($parent in $parentGroups) { try { foreach ($a in Invoke-EGIGraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$($parent.id)/appRoleAssignments") { $appRoleAssignments.Add([pscustomobject]@{ resourceDisplayName = $a.resourceDisplayName; appRoleId = $a.appRoleId; Source = "Nested via '$($parent.displayName)'" }) } } catch { Write-Warning "Could not read app role assignments for parent group '$($parent.displayName)': $($_.Exception.Message)" } } # --- PIM eligibility, only relevant for role-assignable groups ------- # Not extended to parent groups: Entra does not allow a role-assignable # group to be nested inside another role-assignable group, so PIM # eligibility never flows through group nesting in practice. $pimEligibility = @() if ($group.isAssignableToRole) { try { $pimEligibility = Invoke-EGIGraphPaged -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilityScheduleInstances?`$filter=principalId eq '$GroupId'" } catch { Write-Warning "Could not read PIM eligibility for group '$($group.displayName)' - check RoleManagement.Read.Directory permission: $($_.Exception.Message)" } } # --- Teams / SharePoint provisioning (Microsoft 365 groups only) ---- $isTeamsGroup = $group.resourceProvisioningOptions -contains 'Team' $totalDependencies = $caMatches.Count + $licenses.Count + $appRoleAssignments.Count + @($pimEligibility).Count + [int]$isTeamsGroup $riskLevel = switch ($true) { { $totalDependencies -eq 0 } { 'None'; break } { @($pimEligibility).Count -gt 0 -or ($caMatches | Where-Object State -EQ 'enabled') } { 'Critical'; break } { $totalDependencies -ge 3 } { 'High'; break } { $totalDependencies -ge 2 } { 'Medium'; break } default { 'Low' } } [pscustomobject]@{ GroupId = $GroupId DisplayName = $group.displayName IsRoleAssignable = [bool]$group.isAssignableToRole IsTeamsGroup = $isTeamsGroup MembershipRule = $group.membershipRule ParentGroups = @($parentGroups | Select-Object id, displayName, groupTypes) ChildGroups = @($childGroups | Select-Object id, displayName, groupTypes) RuleReferencedGroups = @($ruleReferencedGroups) RuleReferencedByGroups = @($ruleReferencedByGroups) ConditionalAccessPolicies = @($caMatches) AssignedLicenses = @($licenses) AppRoleAssignments = @($appRoleAssignments) PimEligibleRoleCount = @($pimEligibility).Count TotalDependencyCount = $totalDependencies RiskLevel = $riskLevel } } |