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
    }
}