Public/Compare-SPPermissionScan.ps1
|
function Compare-SPPermissionScan { <# .SYNOPSIS Compares two scans and produces a delta report (what changed since the last scan?). .DESCRIPTION Both scans are normalized first (legacy codes, limited-access noise), sites are matched by URL and nodes by path. Reported per site: new/removed inheritance breaks, added/removed/changed permission assignments (at the nodes where they are assigned), new/removed rows, new/removed sharing links and group membership changes (people added to or removed from groups, resolved transitively). Writes a self-contained HTML delta report; -PassThru additionally returns the delta object. .PARAMETER ReferencePath The older scan JSON (baseline). .PARAMETER DifferencePath The newer scan JSON. .PARAMETER OutputPath Path of the HTML delta report. Default: SPPermissionDelta_<timestamp>.html next to the newer scan. .PARAMETER PassThru Also return the delta object (in addition to writing the HTML report). .PARAMETER Force Skip the comparability check (scans with different depth are refused by default, because a shallower scan would report every deeper row as removed). .PARAMETER LogoBase64 Optional logo (base64 PNG) for the report header. .PARAMETER AccentColor Optional accent color (#rrggbb) for the report header. .PARAMETER Open Opens the delta report afterwards. .EXAMPLE Compare-SPPermissionScan -ReferencePath .\scan_old.json -DifferencePath .\scan_new.json -Open #> [CmdletBinding(SupportsShouldProcess)] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory, Position = 0)] [string]$ReferencePath, [Parameter(Mandatory, Position = 1)] [string]$DifferencePath, [string]$OutputPath, [switch]$PassThru, [switch]$Force, [string]$LogoBase64, [ValidatePattern('^$|^#[0-9a-fA-F]{6}$')] [string]$AccentColor, [switch]$Open ) $ref = Get-SPMScanObject -InputObject $ReferencePath $dif = Get-SPMScanObject -InputObject $DifferencePath if (-not $Force -and $null -ne $ref.depth -and $null -ne $dif.depth -and [int]$ref.depth -ne [int]$dif.depth) { throw "Scans are not comparable: reference was scanned with depth $($ref.depth), difference with depth $($dif.depth). A depth mismatch would report every deeper row as added/removed. Re-scan with identical settings, or pass -Force to compare anyway." } $groupy = @('SharePointGroup', 'EntraGroup', 'M365Group', 'SecurityGroup', 'SharingLink') $normUrl = { param($u) ([string]$u).TrimEnd('/').ToLowerInvariant() } $indexSite = { param($Site) $principalById = @{} $byLogin = @{} foreach ($p in @($Site.principals)) { $principalById[[string]$p.id] = $p $byLogin[([string]$p.loginName).ToLowerInvariant()] = $p } $nodesByUrl = [ordered]@{} foreach ($n in @($Site.nodes)) { $nodesByUrl[(& $normUrl $n.url)] = $n } @{ Site = $Site; PrincipalById = $principalById; ByLogin = $byLogin; NodesByUrl = $nodesByUrl; PersonMap = (Get-SPMPersonMap -Site $Site) } } $assignMap = { param($Node, $Index) $m = @{} foreach ($a in @($Node.assignments)) { $p = $Index.PrincipalById[[string]$a.principalId] if ($p) { $m[([string]$p.loginName).ToLowerInvariant()] = @{ Permission = [string]$a.permission; Name = [string]$p.name } } } $m } $linkKind = { param($Name) if ([string]$Name -match '^SharingLinks\.[0-9a-fA-F-]{36}\.([A-Za-z]+)\.') { switch ($Matches[1]) { 'Flexible' { 'Specific people' } 'OrganizationView' { 'Organization (view)' } 'OrganizationEdit' { 'Organization (edit)' } 'AnonymousView' { 'Anyone with the link (view)' } 'AnonymousEdit' { 'Anyone with the link (edit)' } default { $Matches[1] } } } else { [string]$Name } } $refSites = @{} foreach ($s in @($ref.sites)) { $refSites[(& $normUrl $s.url)] = $s } $difSites = @{} foreach ($s in @($dif.sites)) { $difSites[(& $normUrl $s.url)] = $s } $siteResults = [System.Collections.Generic.List[object]]::new() $totalChanges = 0 foreach ($key in @($difSites.Keys) + @($refSites.Keys | Where-Object { -not $difSites.ContainsKey($_) })) { $inRef = $refSites.ContainsKey($key) $inDif = $difSites.ContainsKey($key) $siteObj = if ($inDif) { $difSites[$key] } else { $refSites[$key] } if (-not ($inRef -and $inDif)) { $siteResults.Add([pscustomobject]@{ url = $siteObj.url; title = $siteObj.title status = $(if ($inDif) { 'added' } else { 'removed' }) summary = [pscustomobject]@{ total = 1 } addedNodes = @(); removedNodes = @(); newBreaks = @(); restoredInheritance = @() addedAssignments = @(); removedAssignments = @(); changedAssignments = @() addedSharingLinks = @(); removedSharingLinks = @(); membershipChanges = @() }) $totalChanges++ continue } $ri = & $indexSite $refSites[$key] $di = & $indexSite $difSites[$key] $addedNodes = [System.Collections.Generic.List[object]]::new() $removedNodes = [System.Collections.Generic.List[object]]::new() $newBreaks = [System.Collections.Generic.List[object]]::new() $restored = [System.Collections.Generic.List[object]]::new() $addedAssign = [System.Collections.Generic.List[object]]::new() $removedAssign = [System.Collections.Generic.List[object]]::new() $changedAssign = [System.Collections.Generic.List[object]]::new() foreach ($u in $di.NodesByUrl.Keys) { if (-not $ri.NodesByUrl.Contains($u)) { $n = $di.NodesByUrl[$u] $addedNodes.Add([pscustomobject]@{ title = $n.title; url = $n.url }) } } foreach ($u in $ri.NodesByUrl.Keys) { if (-not $di.NodesByUrl.Contains($u)) { $n = $ri.NodesByUrl[$u] $removedNodes.Add([pscustomobject]@{ title = $n.title; url = $n.url }) } } foreach ($u in $di.NodesByUrl.Keys) { if (-not $ri.NodesByUrl.Contains($u)) { continue } $rn = $ri.NodesByUrl[$u] $dn = $di.NodesByUrl[$u] $refUnique = [bool]$rn.hasUniquePermissions $difUnique = [bool]$dn.hasUniquePermissions if (-not $refUnique -and -not $difUnique) { continue } if ($difUnique -and -not $refUnique) { $detail = (@($dn.assignments) | ForEach-Object { $p = $di.PrincipalById[[string]$_.principalId] if ($p) { "$($p.name): $($_.permission)" } }) -join ' · ' $newBreaks.Add([pscustomobject]@{ node = $dn.title; url = $dn.url; detail = $detail }) continue } if ($refUnique -and -not $difUnique) { $restored.Add([pscustomobject]@{ node = $dn.title; url = $dn.url }) continue } $rm = & $assignMap $rn $ri $dm = & $assignMap $dn $di foreach ($login in $dm.Keys) { if (-not $rm.ContainsKey($login)) { $addedAssign.Add([pscustomobject]@{ node = $dn.title; url = $dn.url; principal = $dm[$login].Name; permission = $dm[$login].Permission }) } elseif ($rm[$login].Permission -ne $dm[$login].Permission) { $changedAssign.Add([pscustomobject]@{ node = $dn.title; url = $dn.url; principal = $dm[$login].Name; from = $rm[$login].Permission; to = $dm[$login].Permission }) } } foreach ($login in $rm.Keys) { if (-not $dm.ContainsKey($login)) { $removedAssign.Add([pscustomobject]@{ node = $rn.title; url = $rn.url; principal = $rm[$login].Name; permission = $rm[$login].Permission }) } } } $addedLinks = [System.Collections.Generic.List[object]]::new() $removedLinks = [System.Collections.Generic.List[object]]::new() foreach ($login in $di.ByLogin.Keys) { $p = $di.ByLogin[$login] if ($p.type -eq 'SharingLink' -and -not $ri.ByLogin.ContainsKey($login)) { $people = if ($di.PersonMap.PerPrincipal.ContainsKey([string]$p.id)) { $di.PersonMap.PerPrincipal[[string]$p.id].Count } else { 0 } $addedLinks.Add([pscustomobject]@{ kind = (& $linkKind $p.name); people = $people }) } } foreach ($login in $ri.ByLogin.Keys) { $p = $ri.ByLogin[$login] if ($p.type -eq 'SharingLink' -and -not $di.ByLogin.ContainsKey($login)) { $removedLinks.Add([pscustomobject]@{ kind = (& $linkKind $p.name) }) } } $membership = [System.Collections.Generic.List[object]]::new() foreach ($login in $di.ByLogin.Keys) { if (-not $ri.ByLogin.ContainsKey($login)) { continue } $dp = $di.ByLogin[$login] $rp = $ri.ByLogin[$login] if ($dp.type -notin $groupy) { continue } $refSet = $ri.PersonMap.PerPrincipal[[string]$rp.id] $difSet = $di.PersonMap.PerPrincipal[[string]$dp.id] if ($null -eq $refSet -or $null -eq $difSet) { continue } $addedPeople = @($difSet | Where-Object { -not $refSet.Contains($_) } | ForEach-Object { $k = $_; $pi = $di.PersonMap.Persons | Where-Object key -eq $k | Select-Object -First 1; if ($pi) { $pi.name } else { $k } }) $removedPeople = @($refSet | Where-Object { -not $difSet.Contains($_) } | ForEach-Object { $k = $_; $pi = $ri.PersonMap.Persons | Where-Object key -eq $k | Select-Object -First 1; if ($pi) { $pi.name } else { $k } }) if ($addedPeople.Count -or $removedPeople.Count) { $label = if ($dp.type -eq 'SharingLink') { (& $linkKind $dp.name) + ' ⚠' } else { [string]$dp.name } $membership.Add([pscustomobject]@{ group = $label; added = $addedPeople; removed = $removedPeople }) } } $siteTotal = $addedNodes.Count + $removedNodes.Count + $newBreaks.Count + $restored.Count + $addedAssign.Count + $removedAssign.Count + $changedAssign.Count + $addedLinks.Count + $removedLinks.Count + $membership.Count $totalChanges += $siteTotal $siteResults.Add([pscustomobject]@{ url = $siteObj.url; title = $siteObj.title; status = 'both' summary = [pscustomobject]@{ total = $siteTotal } addedNodes = $addedNodes.ToArray(); removedNodes = $removedNodes.ToArray() newBreaks = $newBreaks.ToArray(); restoredInheritance = $restored.ToArray() addedAssignments = $addedAssign.ToArray(); removedAssignments = $removedAssign.ToArray(); changedAssignments = $changedAssign.ToArray() addedSharingLinks = $addedLinks.ToArray(); removedSharingLinks = $removedLinks.ToArray() membershipChanges = $membership.ToArray() }) } $delta = [pscustomobject]@{ reference = [pscustomobject]@{ path = $ReferencePath; scanDate = $ref.scanDate; generator = $ref.generator } difference = [pscustomobject]@{ path = $DifferencePath; scanDate = $dif.scanDate; generator = $dif.generator } generated = (Get-Date -Format 'yyyy-MM-dd HH:mm') totalChanges = $totalChanges sites = $siteResults.ToArray() } if (-not $OutputPath) { $dir = Split-Path -Path (Resolve-Path -Path $DifferencePath) -Parent $OutputPath = Join-Path $dir ('SPPermissionDelta_{0:yyyyMMdd_HHmmss}.html' -f (Get-Date)) } if ($PSCmdlet.ShouldProcess($OutputPath, 'Create HTML delta report')) { $htmlParams = @{ Delta = $delta; Reference = $ref; Difference = $dif } if ($LogoBase64) { $htmlParams.LogoBase64 = $LogoBase64 } if ($AccentColor) { $htmlParams.AccentColor = $AccentColor } Get-SPMDeltaHtml @htmlParams | Set-Content -Path $OutputPath -Encoding utf8 Write-Verbose "Delta report saved: $OutputPath" if ($Open) { Invoke-Item -Path $OutputPath } if ($PassThru) { return $delta } return (Get-Item -Path $OutputPath) } elseif ($PassThru) { return $delta } } |