Public/Export-EGIGroupBlastRadiusSvg.ps1
|
function Export-EGIGroupBlastRadiusSvg { <# .SYNOPSIS Renders a Get-EGIGroupBlastRadius result as a standalone SVG relationship diagram. .DESCRIPTION Draws the group as a hub node in the center-left, and every downstream dependency (Conditional Access policies, license SKUs, app role assignments, PIM eligibility, structural parent/child group nesting, groups referenced by this group's memberOf rule clause, and other dynamic groups that reference this group the same way) as spoke nodes grouped into color-coded columns, connected back to the hub with curved lines. Conditional Access, license, and app role entries inherited through a parent group (rather than referencing this group directly) are labeled with where they actually come from, e.g. "(Nested via 'Contoso-EU')". If the blast radius object carries a dynamic group's MembershipRule, it is drawn as its own box wired to the hub, so the diagram shows not just what depends on the group but why members end up in it. Passing -ExampleUser (or -ExampleUserId) additionally draws a sample user node wired to the hub, evaluated against the membership rule (via Test-EGIRuleTreeNode) and colored green/red depending on whether that user would match - a concrete "would this person be a member" illustration next to the abstract rule text. The output is a plain, self-contained .svg file - no external tools or libraries required. Open it directly in a browser, embed it in a wiki page, or attach it to a change-ticket as evidence of the blast radius before a rule change. Renders exactly one blast radius per call: piping several objects into a single -Path raises an error instead of silently keeping only the last. .PARAMETER BlastRadius A result object from Get-EGIGroupBlastRadius. .PARAMETER Path Output file path, e.g. './reports/sales-de-blast-radius.svg'. .PARAMETER ExampleUser Optional sample user object ([pscustomobject] or hashtable, e.g. from Get-MgUser) drawn as its own node and checked against $BlastRadius.MembershipRule to illustrate whether that user would be a member. Property names must match the Graph attribute names used in the rule (department, jobTitle, country, ...), case-insensitive. Mutually exclusive with -ExampleUserId. .PARAMETER ExampleUserId Object ID or userPrincipalName of a real user to look up via Microsoft Graph (Connect-MgGraph, at least User.Read.All) and draw the same way as -ExampleUser. Only the properties referenced by $BlastRadius.MembershipRule (plus id/displayName) are requested, so the rule decides what gets fetched. Mutually exclusive with -ExampleUser. .EXAMPLE Get-EGIGroupBlastRadius -GroupId $id | Export-EGIGroupBlastRadiusSvg -Path './report.svg' .EXAMPLE $exampleUser = [pscustomobject]@{ DisplayName = 'Alice Nguyen'; department = 'Sales'; country = 'DE' } Get-EGIGroupBlastRadius -GroupId $id | Export-EGIGroupBlastRadiusSvg -Path './report.svg' -ExampleUser $exampleUser .EXAMPLE Get-EGIGroupBlastRadius -GroupId $id | Export-EGIGroupBlastRadiusSvg -Path './report.svg' -ExampleUserId 'alice.nguyen@contoso.com' #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [pscustomobject] $BlastRadius, [Parameter(Mandatory)] [string] $Path, [object] $ExampleUser, [string] $ExampleUserId ) begin { $received = [System.Collections.Generic.List[object]]::new() } process { $received.Add($BlastRadius) } end { if ($received.Count -gt 1) { throw "Export-EGIGroupBlastRadiusSvg received $($received.Count) blast-radius objects, but -Path '$Path' names a single file. Export one group per call (loop and vary -Path for multiple groups)." } $BlastRadius = $received[0] if ($ExampleUser -and $ExampleUserId) { throw "Specify either -ExampleUser or -ExampleUserId, not both." } if ($ExampleUserId) { $selectProps = [System.Collections.Generic.List[string]]::new() $selectProps.Add('id') $selectProps.Add('displayName') if (-not [string]::IsNullOrWhiteSpace($BlastRadius.MembershipRule)) { foreach ($m in [regex]::Matches($BlastRadius.MembershipRule, '(?<!\w)user\.([A-Za-z_][A-Za-z0-9_]*)', 'IgnoreCase')) { $propName = $m.Groups[1].Value if ($selectProps -notcontains $propName) { $selectProps.Add($propName) } } } try { $ExampleUser = Invoke-MgGraphRequest -Method GET ` -Uri "https://graph.microsoft.com/v1.0/users/$ExampleUserId`?`$select=$($selectProps -join ',')" } catch { throw "Could not fetch example user '$ExampleUserId' from Microsoft Graph: $($_.Exception.Message)" } } # ---- Build the category list --------------------------------------- function Format-EGISourceSuffix { param([string] $Source) if ($Source -and $Source -ne 'Direct') { return ' ' + ($Source -replace '^Nested ', '') } return '' } $categories = @( [pscustomobject]@{ Name = 'Conditional Access' Color = '#2563eb' Items = @($BlastRadius.ConditionalAccessPolicies | ForEach-Object { "$($_.DisplayName) [$($_.Reference) / $($_.State)]$(Format-EGISourceSuffix $_.Source)" }) } [pscustomobject]@{ Name = 'Licenses' Color = '#16a34a' Items = @($BlastRadius.AssignedLicenses | ForEach-Object { "$($_.SkuId)$(Format-EGISourceSuffix $_.Source)" }) } [pscustomobject]@{ Name = 'App role assignments' Color = '#d97706' Items = @($BlastRadius.AppRoleAssignments | ForEach-Object { "$($_.resourceDisplayName)$(Format-EGISourceSuffix $_.Source)" }) } [pscustomobject]@{ Name = 'PIM eligibility' Color = '#dc2626' Items = @(if ($BlastRadius.PimEligibleRoleCount -gt 0) { "$($BlastRadius.PimEligibleRoleCount) eligible role assignment(s)" }) } [pscustomobject]@{ Name = 'Nested in (parent groups)' Color = '#7c3aed' Items = @($BlastRadius.ParentGroups | ForEach-Object { $_.displayName }) } [pscustomobject]@{ Name = 'Contains (nested groups)' Color = '#0891b2' Items = @($BlastRadius.ChildGroups | ForEach-Object { $_.displayName }) } [pscustomobject]@{ Name = 'Rule references (memberOf)' Color = '#be185d' Items = @($BlastRadius.RuleReferencedGroups | ForEach-Object { $_.displayName }) } [pscustomobject]@{ Name = 'Referenced by (memberOf)' Color = '#c026d3' Items = @($BlastRadius.RuleReferencedByGroups | ForEach-Object { $_.displayName }) } ) # ---- Layout constants ------------------------------------------------ $colWidth = 300 $colGap = 60 $itemHeight = 34 $itemGap = 10 $topMargin = 60 $hubWidth = 220 $hubHeight = 70 $hubX = 20 $stackGap = 20 $colX = @(0..($categories.Count - 1)) | ForEach-Object { 340 + $_ * ($colWidth + $colGap) } $maxItemsInAnyColumn = ($categories | ForEach-Object { [Math]::Max($_.Items.Count, 1) } | Measure-Object -Maximum).Maximum $width = 340 + ($categories.Count * ($colWidth + $colGap)) function ConvertTo-SafeXml { param([string] $Text) if ($null -eq $Text) { return '' } return [System.Security.SecurityElement]::Escape($Text) } function Get-EGIWrappedLines { param([string] $Text, [int] $MaxCharsPerLine = 40, [int] $MaxLines = 6) $words = $Text -split '\s+' $lines = [System.Collections.Generic.List[string]]::new() $current = '' foreach ($word in $words) { $candidate = if ($current) { "$current $word" } else { $word } if ($candidate.Length -gt $MaxCharsPerLine -and $current) { $lines.Add($current) $current = $word } else { $current = $candidate } } if ($current) { $lines.Add($current) } if ($lines.Count -gt $MaxLines) { $lines = $lines[0..($MaxLines - 1)] $lines[$MaxLines - 1] = $lines[$MaxLines - 1] + ' ...' } return $lines } # ---- Membership rule box (below the hub) ------------------------------ $ruleLines = @() if (-not [string]::IsNullOrWhiteSpace($BlastRadius.MembershipRule)) { $ruleLines = @(Get-EGIWrappedLines -Text $BlastRadius.MembershipRule) } $ruleBoxWidth = 300 $ruleLineHeight = 16 $ruleBoxHeight = if ($ruleLines.Count -gt 0) { 34 + ($ruleLines.Count * $ruleLineHeight) } else { 0 } # ---- Example user box (above the hub) --------------------------------- $exampleName = $null $examplePropLines = @() $exampleMatches = $null $exampleError = $null if ($ExampleUser) { $exampleName = Get-EGIUserProperty -Obj $ExampleUser -Name 'DisplayName' if ([string]::IsNullOrWhiteSpace($exampleName)) { $exampleName = 'Example user' } $propNames = if ($ExampleUser -is [System.Collections.IDictionary]) { @($ExampleUser.Keys) } else { @($ExampleUser.PSObject.Properties.Name) } $propNames = @($propNames | Where-Object { $_ -notin @('Id', 'DisplayName') } | Select-Object -First 3) $examplePropLines = @(foreach ($p in $propNames) { "$p`: $(Get-EGIUserProperty -Obj $ExampleUser -Name $p)" }) if (-not [string]::IsNullOrWhiteSpace($BlastRadius.MembershipRule)) { try { $exampleTree = ConvertFrom-EGIRuleString -Rule $BlastRadius.MembershipRule $exampleMatches = [bool](Test-EGIRuleTreeNode -Node $exampleTree -User $ExampleUser) } catch { $exampleError = $_.Exception.Message } } } $userBoxWidth = 220 $userBoxHeight = if ($ExampleUser) { 40 + ($examplePropLines.Count * 15) + 20 } else { 0 } # ---- Overall canvas + vertical hub/rule/user stack -------------------- $leftStackHeight = $hubHeight if ($ExampleUser) { $leftStackHeight += $userBoxHeight + $stackGap } if ($ruleBoxHeight -gt 0) { $leftStackHeight += $ruleBoxHeight + $stackGap } $height = [Math]::Max( [Math]::Max(($maxItemsInAnyColumn * ($itemHeight + $itemGap)) + $topMargin + 80, 260), $leftStackHeight + $topMargin + 40 ) $stackStartY = [Math]::Max(60, [Math]::Round(($height - $leftStackHeight) / 2)) $cursorY = $stackStartY $userBoxY = $cursorY if ($ExampleUser) { $cursorY += $userBoxHeight + $stackGap } $hubY = $cursorY $cursorY += $hubHeight + $stackGap $ruleBoxY = $cursorY $hubCenterY = $hubY + ($hubHeight / 2) $hubRightEdgeX = $hubX + $hubWidth $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine("<svg xmlns=`"http://www.w3.org/2000/svg`" viewBox=`"0 0 $width $height`" font-family=`"Segoe UI, Arial, sans-serif`">") [void]$sb.AppendLine("<rect x=`"0`" y=`"0`" width=`"$width`" height=`"$height`" fill=`"#ffffff`"/>") # Title + risk badge $title = ConvertTo-SafeXml "Blast radius: $($BlastRadius.DisplayName)" [void]$sb.AppendLine("<text x=`"20`" y=`"28`" font-size=`"18`" font-weight=`"600`" fill=`"#111827`">$title</text>") $riskColor = switch ($BlastRadius.RiskLevel) { 'Critical' { '#dc2626' } 'High' { '#d97706' } 'Medium' { '#ca8a04' } 'Low' { '#16a34a' } default { '#6b7280' } } [void]$sb.AppendLine("<text x=`"20`" y=`"48`" font-size=`"13`" fill=`"$riskColor`" font-weight=`"600`">Risk: $($BlastRadius.RiskLevel) | Total dependencies: $($BlastRadius.TotalDependencyCount)</text>") # Hub node (the group itself) [void]$sb.AppendLine("<rect x=`"$hubX`" y=`"$hubY`" width=`"$hubWidth`" height=`"$hubHeight`" rx=`"10`" fill=`"#eef2ff`" stroke=`"#4338ca`" stroke-width=`"1.5`"/>") [void]$sb.AppendLine("<text x=`"$($hubX + $hubWidth/2)`" y=`"$($hubY + $hubHeight/2 - 4)`" text-anchor=`"middle`" font-size=`"13`" font-weight=`"600`" fill=`"#312e81`">$(ConvertTo-SafeXml $BlastRadius.DisplayName)</text>") [void]$sb.AppendLine("<text x=`"$($hubX + $hubWidth/2)`" y=`"$($hubY + $hubHeight/2 + 16)`" text-anchor=`"middle`" font-size=`"11`" fill=`"#4338ca`">Dynamic group</text>") # Membership rule box (below the hub) if ($ruleBoxHeight -gt 0) { $hubBottomCenterX = $hubX + $hubWidth / 2 [void]$sb.AppendLine("<path d=`"M $hubBottomCenterX $($hubY + $hubHeight) L $hubBottomCenterX $ruleBoxY`" fill=`"none`" stroke=`"#b45309`" stroke-width=`"1.5`"/>") [void]$sb.AppendLine("<rect x=`"$hubX`" y=`"$ruleBoxY`" width=`"$ruleBoxWidth`" height=`"$ruleBoxHeight`" rx=`"8`" fill=`"#fffbeb`" stroke=`"#b45309`" stroke-width=`"1.5`"/>") $ruleTextX = $hubX + 12 $ruleTextY = $ruleBoxY + 20 [void]$sb.AppendLine("<text x=`"$ruleTextX`" y=`"$ruleTextY`" font-size=`"12`" font-weight=`"600`" fill=`"#92400e`">Membership rule</text>") $ruleTextY += 18 foreach ($line in $ruleLines) { [void]$sb.AppendLine("<text x=`"$ruleTextX`" y=`"$ruleTextY`" font-size=`"11`" font-family=`"Consolas, monospace`" fill=`"#78350f`">$(ConvertTo-SafeXml $line)</text>") $ruleTextY += $ruleLineHeight } } # Example user node (above the hub), checked against the membership rule if ($ExampleUser) { $userColor = if ($exampleError) { '#6b7280' } elseif ($exampleMatches -eq $true) { '#16a34a' } elseif ($exampleMatches -eq $false) { '#dc2626' } else { '#6b7280' } $userFill = if ($exampleError) { '#f9fafb' } elseif ($exampleMatches -eq $true) { '#f0fdf4' } elseif ($exampleMatches -eq $false) { '#fef2f2' } else { '#f9fafb' } $userCenterX = $hubX + $userBoxWidth / 2 [void]$sb.AppendLine("<rect x=`"$hubX`" y=`"$userBoxY`" width=`"$userBoxWidth`" height=`"$userBoxHeight`" rx=`"10`" fill=`"$userFill`" stroke=`"$userColor`" stroke-width=`"1.5`"/>") $nameY = $userBoxY + 20 [void]$sb.AppendLine("<text x=`"$userCenterX`" y=`"$nameY`" text-anchor=`"middle`" font-size=`"13`" font-weight=`"600`" fill=`"#111827`">$(ConvertTo-SafeXml $exampleName)</text>") $propTextY = $nameY + 17 foreach ($line in $examplePropLines) { [void]$sb.AppendLine("<text x=`"$userCenterX`" y=`"$propTextY`" text-anchor=`"middle`" font-size=`"11`" fill=`"#374151`">$(ConvertTo-SafeXml $line)</text>") $propTextY += 15 } $verdict = if ($exampleError) { "Rule error: $exampleError" } elseif ($exampleMatches -eq $true) { 'Matches rule' } elseif ($exampleMatches -eq $false) { 'Does not match rule' } else { 'Example user' } [void]$sb.AppendLine("<text x=`"$userCenterX`" y=`"$($userBoxY + $userBoxHeight - 8)`" text-anchor=`"middle`" font-size=`"11`" font-weight=`"600`" fill=`"$userColor`">$(ConvertTo-SafeXml $verdict)</text>") [void]$sb.AppendLine("<path d=`"M $userCenterX $($userBoxY + $userBoxHeight) L $userCenterX $hubY`" fill=`"none`" stroke=`"$userColor`" stroke-width=`"1.5`" stroke-dasharray=`"4,3`"/>") } for ($c = 0; $c -lt $categories.Count; $c++) { $cat = $categories[$c] $x = $colX[$c] [void]$sb.AppendLine("<text x=`"$x`" y=`"$($topMargin - 20)`" font-size=`"13`" font-weight=`"600`" fill=`"$($cat.Color)`">$(ConvertTo-SafeXml $cat.Name) ($($cat.Items.Count))</text>") if ($cat.Items.Count -eq 0) { [void]$sb.AppendLine("<text x=`"$x`" y=`"$topMargin`" font-size=`"12`" fill=`"#9ca3af`">(none)</text>") continue } for ($r = 0; $r -lt $cat.Items.Count; $r++) { $y = $topMargin + $r * ($itemHeight + $itemGap) $labelRaw = [string]$cat.Items[$r] if ($labelRaw.Length -gt 40) { $labelRaw = $labelRaw.Substring(0, 37) + '...' } $label = ConvertTo-SafeXml $labelRaw $itemCenterY = $y + ($itemHeight / 2) $ctrlX = ($hubRightEdgeX + $x) / 2 [void]$sb.AppendLine("<path d=`"M $hubRightEdgeX $hubCenterY Q $ctrlX $itemCenterY $x $itemCenterY`" fill=`"none`" stroke=`"$($cat.Color)`" stroke-width=`"1`" opacity=`"0.45`"/>") [void]$sb.AppendLine("<rect x=`"$x`" y=`"$y`" width=`"$colWidth`" height=`"$itemHeight`" rx=`"6`" fill=`"#ffffff`" stroke=`"$($cat.Color)`" stroke-width=`"1`"/>") [void]$sb.AppendLine("<text x=`"$($x + 10)`" y=`"$($itemCenterY + 4)`" font-size=`"12`" fill=`"#111827`">$label</text>") } } [void]$sb.AppendLine('</svg>') # BOM-less UTF-8 on every PowerShell edition (5.1's Set-Content writes a BOM). $resolvedPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) [System.IO.File]::WriteAllText($resolvedPath, $sb.ToString(), [System.Text.UTF8Encoding]::new($false)) Write-Verbose "Wrote blast-radius SVG report to $Path" return (Get-Item -LiteralPath $resolvedPath) } } |