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 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]$Open ) $ref = Get-SPMScanObject -InputObject $ReferencePath $dif = Get-SPMScanObject -InputObject $DifferencePath $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')) { Get-SPMDeltaHtml -Delta $delta | 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 } } |