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