modules/shared/Policy/PolicyEnforcementRenderer.ps1
|
# PolicyEnforcementRenderer.ps1 # Track C scaffold (#431). Stub only. Implementation lands after Foundation #435. # Emits Cytoscape JSON for the policy enforcement graph layer (assignments, exemptions, # inheritance, compliance heatmap). Reuses tier-aware rendering from Track A (#428). Set-StrictMode -Version Latest function Invoke-PolicyEnforcementRender { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Entities, [Parameter(Mandatory)] [object[]] $Edges, [Parameter(Mandatory)] [hashtable] $ComplianceState, [int] $Tier = 1 ) return New-PolicyEnforcementGraph -Entities $Entities -Edges $Edges -ComplianceState $ComplianceState -Tier $Tier } function New-PolicyEnforcementGraph { <# .SYNOPSIS Build the Cytoscape JSON payload for the policy enforcement graph layer. .PARAMETER Entities v3 entity set (scopes, assignments, definitions, exemptions, resources). .PARAMETER Edges Edges of relations PolicyAssignedTo, PolicyEnforces, ExemptedFrom, InheritsFrom. .PARAMETER ComplianceState Per-scope compliance percentages keyed by canonical scope id. .PARAMETER Tier Render tier (1, 2, 3). Tier 2+ uses query-on-demand (Track V #430). .OUTPUTS Hashtable shaped for Cytoscape consumption. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Entities, [Parameter(Mandatory)] [object[]] $Edges, [Parameter(Mandatory)] [hashtable] $ComplianceState, [int] $Tier = 1 ) $scopeTypes = @('Tenant', 'ManagementGroup', 'Subscription', 'ResourceGroup', 'AzureResource') $policyTypes = @('PolicyExemption', 'PolicyAssignment', 'PolicyDefinition') $nodes = [System.Collections.Generic.List[object]]::new() $renderedEntityIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($entity in @($Entities)) { if ($null -eq $entity) { continue } $entityType = if ($entity.PSObject.Properties['EntityType']) { [string]$entity.EntityType } else { '' } $entityId = if ($entity.PSObject.Properties['EntityId']) { [string]$entity.EntityId } else { '' } if ([string]::IsNullOrWhiteSpace($entityId)) { continue } if (-not ($entityType -in $scopeTypes -or $entityType -in $policyTypes)) { continue } if ($renderedEntityIds.Contains($entityId)) { continue } $compliance = 100.0 if ($ComplianceState.ContainsKey($entityId)) { $compliance = [double]$ComplianceState[$entityId] } $label = if ($entity.PSObject.Properties['DisplayName'] -and -not [string]::IsNullOrWhiteSpace([string]$entity.DisplayName)) { [string]$entity.DisplayName } else { $entityId } $tooltip = '' if ($entityType -eq 'PolicyExemption') { $tooltip = Format-ExemptionTooltip -Exemption $entity } $failingAssignments = @() if ($entity.PSObject.Properties['FailingAssignments']) { $failingAssignments = @($entity.FailingAssignments) } $nodes.Add([pscustomobject]@{ data = [pscustomobject]@{ id = $entityId label = $label entityType = $entityType compliancePercent = [Math]::Round($compliance, 2) heatmapColor = Get-ComplianceHeatmapColor -Percent $compliance tooltip = $tooltip failingAssignments = @($failingAssignments) } }) | Out-Null $renderedEntityIds.Add($entityId) | Out-Null } $renderedEdges = [System.Collections.Generic.List[object]]::new() foreach ($edge in @($Edges)) { if ($null -eq $edge) { continue } $source = if ($edge.PSObject.Properties['Source']) { [string]$edge.Source } else { '' } $target = if ($edge.PSObject.Properties['Target']) { [string]$edge.Target } else { '' } $relation = if ($edge.PSObject.Properties['Relation']) { [string]$edge.Relation } else { '' } if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($target)) { continue } if (-not ($relation -in @('PolicyAssignedTo', 'PolicyEnforces', 'ExemptedFrom', 'InheritsFrom'))) { continue } if (-not ($renderedEntityIds.Contains($source) -and $renderedEntityIds.Contains($target))) { continue } $renderedEdges.Add([pscustomobject]@{ data = [pscustomobject]@{ id = if ($edge.PSObject.Properties['EdgeId']) { [string]$edge.EdgeId } else { "edge:$source|$relation|$target" } source = $source target = $target relation = $relation style = [pscustomobject]@{ lineStyle = if ($relation -eq 'InheritsFrom') { 'dashed' } else { 'solid' } highlight = ($relation -eq 'ExemptedFrom') } } }) | Out-Null } return @{ metadata = @{ tier = [int]$Tier generatedAt = (Get-Date).ToUniversalTime().ToString('o') } elements = @{ nodes = @($nodes) edges = @($renderedEdges) } } } function Get-ComplianceHeatmapColor { <# .SYNOPSIS Map a compliance percentage [0,100] to one of five sequential heatmap buckets. #> [CmdletBinding()] param( [Parameter(Mandatory)] [double] $Percent ) if ($Percent -ge 100) { return '#2e7d32' } if ($Percent -ge 90) { return '#66bb6a' } if ($Percent -ge 70) { return '#fdd835' } if ($Percent -ge 40) { return '#fb8c00' } return '#c62828' } function Format-ExemptionTooltip { <# .SYNOPSIS Build the hover tooltip for an exemption node, surfacing expiry and reason. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Exemption ) $name = if ($Exemption.PSObject.Properties['DisplayName'] -and $Exemption.DisplayName) { [string]$Exemption.DisplayName } else { 'Policy exemption' } $reason = if ($Exemption.PSObject.Properties['Reason'] -and $Exemption.Reason) { [string]$Exemption.Reason } else { 'No reason provided' } $expires = if ($Exemption.PSObject.Properties['ExpiresOn'] -and $Exemption.ExpiresOn) { [string]$Exemption.ExpiresOn } else { 'No expiry' } return "$name`nReason: $reason`nExpires: $expires" } if ($MyInvocation.MyCommand.Module) { Export-ModuleMember -Function Invoke-PolicyEnforcementRender, New-PolicyEnforcementGraph, Get-ComplianceHeatmapColor, Format-ExemptionTooltip } |