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-SPMEffectivePermission { <# .SYNOPSIS Best (strongest) permission a person effectively has on a node - direct or via any group whose resolved member set contains the person. Sharing links are excluded, matching the person view's default. #> [CmdletBinding()] param($Node, [string]$PersonKey, $PersonMap, [hashtable]$PrincipalById) $best = $null $bestRank = 99 foreach ($a in @($Node.assignments)) { $p = $PrincipalById[[string]$a.principalId] if (-not $p -or $p.type -eq 'SharingLink') { continue } $set = $PersonMap.PerPrincipal[[string]$p.id] if ($set -and $set.Contains($PersonKey)) { $r = Get-SPMPermRank -Permission ([string]$a.permission) if ($r -lt $bestRank) { $bestRank = $r; $best = [string]$a.permission } } } return $best } function Add-SPMPersonDelta { <# .SYNOPSIS Annotates a merged site with person-level deltas so the matrix can highlight changes that happen through group membership (the assignments themselves stay identical): membersDelta on group columns, personDelta (added/removed/changed people with their effective per-node permission changes) for the person view. #> [CmdletBinding()] param( [Parameter(Mandatory)]$MergedSite, [Parameter(Mandatory)]$RefSite, [Parameter(Mandatory)]$DifSite ) $normUrl = { param($u) ([string]$u).TrimEnd('/').ToLowerInvariant() } $loginOf = { param($p) ([string]$p.loginName).ToLowerInvariant() } $refMap = Get-SPMPersonMap -Site $RefSite $difMap = Get-SPMPersonMap -Site $DifSite $refPById = @{} foreach ($p in @($RefSite.principals)) { $refPById[[string]$p.id] = $p } $difPById = @{} foreach ($p in @($DifSite.principals)) { $difPById[[string]$p.id] = $p } $refByLogin = @{} foreach ($p in @($RefSite.principals)) { $refByLogin[(& $loginOf $p)] = $p } $difByLogin = @{} foreach ($p in @($DifSite.principals)) { $difByLogin[(& $loginOf $p)] = $p } $refPersons = @{} foreach ($pers in @($refMap.Persons)) { $refPersons[[string]$pers.key] = $pers } $difPersons = @{} foreach ($pers in @($difMap.Persons)) { $difPersons[[string]$pers.key] = $pers } $candidates = [System.Collections.Generic.HashSet[string]]::new() # 1. group membership changes -> membersDelta on the merged principal columns foreach ($mp in @($MergedSite.principals)) { if ($mp.delta) { continue } # added/removed columns are already marked $l = & $loginOf $mp $rp = $refByLogin[$l] $dp = $difByLogin[$l] if (-not $rp -or -not $dp) { continue } $refSet = $refMap.PerPrincipal[[string]$rp.id] $difSet = $difMap.PerPrincipal[[string]$dp.id] if ($null -eq $refSet -or $null -eq $difSet) { continue } $addedKeys = @($difSet | Where-Object { -not $refSet.Contains($_) }) $removedKeys = @($refSet | Where-Object { -not $difSet.Contains($_) }) if (-not $addedKeys.Count -and -not $removedKeys.Count) { continue } $nameOf = { param($k, $map) $p = $map[$k]; if ($p) { [string]$p.name } else { $k } } $mp | Add-Member -NotePropertyName membersDelta -NotePropertyValue ([pscustomobject]@{ added = @($addedKeys | ForEach-Object { & $nameOf $_ $difPersons }) removed = @($removedKeys | ForEach-Object { & $nameOf $_ $refPersons }) }) -Force foreach ($k in $addedKeys + $removedKeys) { [void]$candidates.Add($k) } } # 2. persons touched by assignment-level changes (a group level change shifts # the effective permission of every member) foreach ($n in @($MergedSite.nodes)) { foreach ($a in @($n.assignments)) { if (-not $a.delta) { continue } $pid = [string]$a.principalId $set = if ($pid.StartsWith('ref_')) { $refMap.PerPrincipal[$pid.Substring(4)] } else { $difMap.PerPrincipal[$pid] } if ($set) { foreach ($k in $set) { [void]$candidates.Add($k) } } } } # 3. persons that exist on only one side foreach ($k in $refPersons.Keys) { if (-not $difPersons.ContainsKey($k)) { [void]$candidates.Add($k) } } foreach ($k in $difPersons.Keys) { if (-not $refPersons.ContainsKey($k)) { [void]$candidates.Add($k) } } if (-not $candidates.Count) { return } $refNodesByUrl = @{} foreach ($n in @($RefSite.nodes)) { $refNodesByUrl[(& $normUrl $n.url)] = $n } $difNodesByUrl = @{} foreach ($n in @($DifSite.nodes)) { $difNodesByUrl[(& $normUrl $n.url)] = $n } $added = [System.Collections.Generic.List[object]]::new() $removed = [System.Collections.Generic.List[object]]::new() $changed = [System.Collections.Generic.List[object]]::new() foreach ($key in $candidates) { $inRef = $refPersons.ContainsKey($key) $inDif = $difPersons.ContainsKey($key) $pers = if ($inDif) { $difPersons[$key] } else { $refPersons[$key] } if ($inDif -and -not $inRef) { $added.Add([pscustomobject]@{ key = $key; name = $pers.name; upn = $pers.upn }) continue } if ($inRef -and -not $inDif) { $cells = [System.Collections.Generic.List[object]]::new() foreach ($n in @($MergedSite.nodes)) { $rn = $refNodesByUrl[(& $normUrl $n.url)] if (-not $rn) { continue } $perm = Get-SPMEffectivePermission -Node $rn -PersonKey $key -PersonMap $refMap -PrincipalById $refPById if ($perm) { $cells.Add([pscustomobject]@{ nodeId = [string]$n.id; perm = $perm }) } } $removed.Add([pscustomobject]@{ key = $key; name = $pers.name; upn = $pers.upn; cells = $cells.ToArray() }) continue } $changes = [System.Collections.Generic.List[object]]::new() foreach ($n in @($MergedSite.nodes)) { $rn = $refNodesByUrl[(& $normUrl $n.url)] $dn = $difNodesByUrl[(& $normUrl $n.url)] $from = if ($rn) { Get-SPMEffectivePermission -Node $rn -PersonKey $key -PersonMap $refMap -PrincipalById $refPById } else { $null } $to = if ($dn) { Get-SPMEffectivePermission -Node $dn -PersonKey $key -PersonMap $difMap -PrincipalById $difPById } else { $null } if ([string]$from -ne [string]$to) { $changes.Add([pscustomobject]@{ nodeId = [string]$n.id; from = $from; to = $to }) } } if ($changes.Count) { $changed.Add([pscustomobject]@{ key = $key; name = $pers.name; upn = $pers.upn; changes = $changes.ToArray() }) } } if ($added.Count -or $removed.Count -or $changed.Count) { $MergedSite | Add-Member -NotePropertyName personDelta -NotePropertyValue ([pscustomobject]@{ added = $added.ToArray(); removed = $removed.ToArray(); changed = $changed.ToArray() }) -Force } } 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)) { $refClone = $refSites[$key] | ConvertTo-Json -Depth 64 | ConvertFrom-Json $m = Merge-SPMDeltaSite -RefSite $refSites[$key] -DifSite $ds Add-SPMPersonDelta -MergedSite $m -RefSite $refClone -DifSite ($Difference.sites | Where-Object { (& $normUrl $_.url) -eq $key } | Select-Object -First 1) $m } 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) } |