Private/Get-SPMDeltaHtml.ps1
|
function Merge-SPMDeltaSite { <# .SYNOPSIS Merges a reference and a difference site (deep-cloned) into one annotated site: principals/nodes/assignments carry a 'delta' field (added|removed|changed) so the report template can render the full matrix with the changes highlighted. #> [CmdletBinding()] param( [Parameter(Mandatory)]$RefSite, [Parameter(Mandatory)]$DifSite ) $normUrl = { param($u) ([string]$u).TrimEnd('/').ToLowerInvariant() } $loginOf = { param($p) ([string]$p.loginName).ToLowerInvariant() } # --- principals: union by loginName; ref-only principals join as 'removed' columns --- $difByLogin = @{} foreach ($p in @($DifSite.principals)) { $difByLogin[(& $loginOf $p)] = $p } $refByLogin = @{} foreach ($p in @($RefSite.principals)) { $refByLogin[(& $loginOf $p)] = $p } $refIdMap = @{} # ref principal id -> merged principal id $removedPrincipals = [System.Collections.Generic.List[object]]::new() foreach ($p in @($RefSite.principals)) { $l = & $loginOf $p if ($difByLogin.ContainsKey($l)) { $refIdMap[[string]$p.id] = [string]$difByLogin[$l].id } else { $newId = 'ref_' + [string]$p.id $refIdMap[[string]$p.id] = $newId $p.id = $newId $p | Add-Member -NotePropertyName delta -NotePropertyValue 'removed' -Force $removedPrincipals.Add($p) } } foreach ($p in @($DifSite.principals)) { if (-not $refByLogin.ContainsKey((& $loginOf $p))) { $p | Add-Member -NotePropertyName delta -NotePropertyValue 'added' -Force } } $DifSite.principals = @($DifSite.principals) + $removedPrincipals.ToArray() # --- nodes: dif order is the backbone; assignments merged per node --- $refNodesByUrl = [ordered]@{} foreach ($n in @($RefSite.nodes)) { $refNodesByUrl[(& $normUrl $n.url)] = $n } $refNodeById = @{} foreach ($n in @($RefSite.nodes)) { $refNodeById[[string]$n.id] = $n } $difUrls = [System.Collections.Generic.HashSet[string]]::new() foreach ($n in @($DifSite.nodes)) { [void]$difUrls.Add((& $normUrl $n.url)) } foreach ($dn in @($DifSite.nodes)) { $rn = $refNodesByUrl[(& $normUrl $dn.url)] if (-not $rn) { $dn | Add-Member -NotePropertyName delta -NotePropertyValue 'added' -Force continue } $refAssign = @{} foreach ($ra in @($rn.assignments)) { $refAssign[$refIdMap[[string]$ra.principalId]] = $ra } $seen = [System.Collections.Generic.HashSet[string]]::new() foreach ($da in @($dn.assignments)) { [void]$seen.Add([string]$da.principalId) $ra = $refAssign[[string]$da.principalId] if (-not $ra) { $da | Add-Member -NotePropertyName delta -NotePropertyValue 'added' -Force } elseif ([string]$ra.permission -ne [string]$da.permission) { $da | Add-Member -NotePropertyName delta -NotePropertyValue 'changed' -Force $da | Add-Member -NotePropertyName oldPermission -NotePropertyValue ([string]$ra.permission) -Force } } $removedCells = [System.Collections.Generic.List[object]]::new() foreach ($mid in $refAssign.Keys) { if (-not $seen.Contains($mid)) { $ra = $refAssign[$mid] $removedCells.Add([pscustomobject]@{ principalId = $mid; permission = [string]$ra.permission roles = @($ra.roles); delta = 'removed' }) } } if ($removedCells.Count) { $dn.assignments = @($dn.assignments) + $removedCells.ToArray() } } # ref-only nodes join as 'removed' rows right below their parent $merged = [System.Collections.Generic.List[object]]::new() $merged.AddRange([object[]]@($DifSite.nodes)) $mergedByUrl = @{} foreach ($n in $merged) { $mergedByUrl[(& $normUrl $n.url)] = $n } foreach ($rn in @($RefSite.nodes)) { $u = & $normUrl $rn.url if ($difUrls.Contains($u)) { continue } $rn | Add-Member -NotePropertyName delta -NotePropertyValue 'removed' -Force $rn.id = 'ref_' + [string]$rn.id foreach ($ra in @($rn.assignments)) { $ra.principalId = $refIdMap[[string]$ra.principalId] } $parent = if ($rn.parentId) { $refNodeById[([string]$rn.parentId -replace '^ref_', '')] } else { $null } if ($parent) { $mp = $mergedByUrl[(& $normUrl $parent.url)] if ($mp) { $rn.parentId = [string]$mp.id $merged.Insert($merged.IndexOf($mp) + 1, $rn) } else { $rn.parentId = $null $merged.Add($rn) } } else { $rn.parentId = $null $merged.Add($rn) } $mergedByUrl[$u] = $rn } $DifSite.nodes = $merged.ToArray() # custom permission level footnotes: union (current wins) if ($RefSite.customRoles) { $roles = [ordered]@{} foreach ($prop in $DifSite.customRoles.PSObject.Properties) { $roles[$prop.Name] = $prop.Value } foreach ($prop in $RefSite.customRoles.PSObject.Properties) { if (-not $roles.Contains($prop.Name)) { $roles[$prop.Name] = $prop.Value } } $DifSite.customRoles = [pscustomobject]$roles } return $DifSite } function Get-SPMDeltaScan { <# .SYNOPSIS Builds the merged, delta-annotated scan object that the report template renders in delta mode (deltaMode=true, per-site deltaSummary, per-cell delta flags). #> [CmdletBinding()] param( [Parameter(Mandatory)]$Delta, [Parameter(Mandatory)]$Reference, [Parameter(Mandatory)]$Difference ) $normUrl = { param($u) ([string]$u).TrimEnd('/').ToLowerInvariant() } # deep clones - annotation must never mutate the caller's data $ref = $Reference | ConvertTo-Json -Depth 64 | ConvertFrom-Json $dif = $Difference | ConvertTo-Json -Depth 64 | ConvertFrom-Json $refSites = @{} foreach ($s in @($ref.sites)) { $refSites[(& $normUrl $s.url)] = $s } $summaries = @{} foreach ($s in @($Delta.sites)) { $summaries[(& $normUrl $s.url)] = $s } $sites = [System.Collections.Generic.List[object]]::new() foreach ($ds in @($dif.sites)) { $key = & $normUrl $ds.url $site = if ($refSites.ContainsKey($key)) { Merge-SPMDeltaSite -RefSite $refSites[$key] -DifSite $ds } else { $ds | Add-Member -NotePropertyName deltaStatus -NotePropertyValue 'added' -Force $ds } $site | Add-Member -NotePropertyName deltaSummary -NotePropertyValue $summaries[$key] -Force $sites.Add($site) } foreach ($key in $refSites.Keys) { $found = $false foreach ($ds in @($dif.sites)) { if ((& $normUrl $ds.url) -eq $key) { $found = $true; break } } if ($found) { continue } $rs = $refSites[$key] $rs | Add-Member -NotePropertyName deltaStatus -NotePropertyValue 'removed' -Force $rs | Add-Member -NotePropertyName deltaSummary -NotePropertyValue $summaries[$key] -Force $sites.Add($rs) } [pscustomobject]@{ scanDate = $dif.scanDate refScanDate = $ref.scanDate depth = $dif.depth generator = $dif.generator siteCount = $sites.Count deltaMode = $true totalChanges = [int]$Delta.totalChanges sites = $sites.ToArray() } } function Get-SPMDeltaHtml { <# .SYNOPSIS Renders a delta (from Compare-SPPermissionScan) as the interactive permission matrix with changes highlighted (yellow = new/changed, red = removed), a "Delta only" toggle and a Changes tab with the classic change tables. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Delta, [Parameter(Mandatory)]$Reference, [Parameter(Mandatory)]$Difference, [string]$Title = 'SharePoint Permission Delta', [string]$LogoBase64, [string]$AccentColor ) $merged = Get-SPMDeltaScan -Delta $Delta -Reference $Reference -Difference $Difference $json = $merged | ConvertTo-Json -Depth 64 $safeJson = $json -replace '</', '<\/' $logoHtml = @' <svg class="logo" viewBox="0 0 46 46" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <rect x="1.5" y="1.5" width="43" height="43" rx="11" fill="rgba(255,255,255,.06)" stroke="rgba(255,255,255,.35)" stroke-width="1.5"/> <rect x="10" y="10" width="7.5" height="7.5" rx="2" fill="#5eead4"/> <rect x="19.5" y="10" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.28)"/> <rect x="29" y="10" width="7.5" height="7.5" rx="2" fill="#38bdf8"/> <rect x="10" y="19.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.16)"/> <rect x="19.5" y="19.5" width="7.5" height="7.5" rx="2" fill="#818cf8"/> <rect x="29" y="19.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.28)"/> <rect x="10" y="29" width="7.5" height="7.5" rx="2" fill="#38bdf8"/> <rect x="19.5" y="29" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.16)"/> <rect x="29" y="29" width="7.5" height="7.5" rx="2" fill="#5eead4"/> </svg> '@ if ($LogoBase64) { $src = if ($LogoBase64.StartsWith('data:')) { $LogoBase64 } else { "data:image/png;base64,$LogoBase64" } $logoHtml = '<img class="logo" src="' + $src + '" alt="logo" style="object-fit:contain;background:#fff;border-radius:10px;padding:3px">' } $brandCss = if ($AccentColor) { ":root { --accent: $AccentColor; } header { background: $AccentColor; }" } else { '' } (Get-SPMHtmlTemplate). Replace('__REPORT_TITLE__', [System.Net.WebUtility]::HtmlEncode($Title)). Replace('__GENERATED__', (Get-Date -Format 'yyyy-MM-dd HH:mm')). Replace('__LOGO_HTML__', $logoHtml). Replace('__BRAND_CSS__', $brandCss). Replace('"__SCAN_DATA__"', $safeJson) } |