Public/Copy-SPMigrationPermission.ps1

function Copy-SPMigrationPermission {
    <#
    .SYNOPSIS
        Reporte les permissions uniques de la source vers la destination.
 
    .DESCRIPTION
        Les travaux de copie serveur-à-serveur ne transportent pas les
        attributions de rôles : un élément dont l'héritage était rompu à la
        source hérite du parent à destination. Cette commande relit les
        permissions explicites côté source et les rejoue côté destination.
 
        Deux garde-fous :
          * les identités sont traduites par la table de correspondance
            (`-IdentityMapPath`) ; sans table, on suppose une migration
            intra-tenant où les principaux sont identiques ;
          * toute identité non résolue est **rapportée**, jamais ignorée en
            silence — une permission perdue sans bruit est le pire résultat
            possible pour une migration.
 
    .PARAMETER SourceListUrl
        URL serveur-relative de la bibliothèque source.
 
    .PARAMETER TargetListUrl
        URL serveur-relative de la bibliothèque de destination.
 
    .PARAMETER IdentityMapPath
        CSV à deux colonnes SourceLogin,TargetLogin.
 
    .PARAMETER ClientId
        ClientId de l'application Entra ID.
 
    .EXAMPLE
        Copy-SPMigrationPermission `
            -SourceSiteUrl https://contoso.sharepoint.com/sites/A `
            -TargetSiteUrl https://contoso.sharepoint.com/sites/B `
            -SourceList 'Documents' -TargetList 'Documents' `
            -IdentityMapPath ./mappings/users.csv -WhatIf
 
    .OUTPUTS
        Objet { Processed, Applied, Skipped, Unmapped, Failures }.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$SourceSiteUrl,
        [Parameter(Mandatory)][string]$TargetSiteUrl,
        [Parameter(Mandatory)][string]$SourceList,
        [Parameter(Mandatory)][string]$TargetList,

        [string]$IdentityMapPath,
        [string]$ClientId = $env:SPM_CLIENT_ID,
        [string]$Tenant = $env:SPM_TENANT,
        [string]$Thumbprint = $env:SPM_CERT_THUMBPRINT,

        # Connexions déjà ouvertes — le moteur passe les siennes plutôt que d'en
        # rouvrir deux au milieu d'une migration.
        $SourceConnection,
        $DestinationConnection,

        # CONSERVÉ POUR COMPATIBILITÉ, sans effet. L'héritage est désormais rompu
        # dès lors que la source l'avait rompu : c'est la seule façon d'obtenir à
        # destination les droits de la source, et pas ceux-là EN PLUS de ceux du
        # parent. Ne l'avoir qu'en option produisait une destination toujours
        # plus permissive que la source.
        [switch]$BreakInheritance,

        # Refuse d'appliquer une permission dont l'identité n'est pas mappée.
        [switch]$StrictIdentityMapping
    )

    if ($BreakInheritance) {
        Write-SPMLog -Level INFO -Operation 'Permissions' -Message (
            "-BreakInheritance est sans effet : l'héritage est rompu à destination chaque fois " +
            "qu'il l'était à la source. C'est ce que « reporter les permissions » veut dire."
        )
    }

    if ($IdentityMapPath) {
        Import-SPMIdentityMap -Path $IdentityMapPath | Out-Null
    }
    else {
        Clear-SPMIdentityMap
        Write-SPMLog -Level WARNING -Operation 'Permissions' -Message (
            'Aucune table de correspondance : les identités source sont réutilisées telles quelles. ' +
            'À ne faire que pour une migration au sein du même tenant.'
        )
    }

    $srcConn = $SourceConnection
    $dstConn = $DestinationConnection

    if (-not $srcConn -or -not $dstConn) {
        $connectSplat = @{ ClientId = $ClientId }
        if ($Thumbprint) {
            if (-not $Tenant) { throw 'Tenant est obligatoire avec un certificat.' }
            $connectSplat.Thumbprint = $Thumbprint
            $connectSplat.Tenant = $Tenant
        }
        else { $connectSplat.Interactive = $true }

        if (-not $srcConn) { $srcConn = Connect-SPMSite @connectSplat -Url $SourceSiteUrl }
        if (-not $dstConn) { $dstConn = Connect-SPMSite @connectSplat -Url $TargetSiteUrl }
    }

    # Racines des deux bibliothèques : le dossier parent d'un élément se
    # transpose de l'une à l'autre, et c'est lui qui lève l'ambiguïté des
    # homonymes. Sans cette transposition, la recherche à destination porte sur
    # le nom seul, en Scope='RecursiveAll' — c'est-à-dire sur toute la
    # bibliothèque.
    $srcRoot = $null
    $dstRoot = $null
    try {
        $srcListe = Invoke-SPMWithRetry -Operation 'list/perm/src' -PassThruErrors -ScriptBlock {
            Get-PnPList -Identity $SourceList -Includes RootFolder -Connection $srcConn -ErrorAction Stop
        }
        $dstListe = Invoke-SPMWithRetry -Operation 'list/perm/dst' -PassThruErrors -ScriptBlock {
            Get-PnPList -Identity $TargetList -Includes RootFolder -Connection $dstConn -ErrorAction Stop
        }
        if ($srcListe) { $srcRoot = Get-SPMListRootUrl -List $srcListe }
        if ($dstListe) { $dstRoot = Get-SPMListRootUrl -List $dstListe }
    }
    catch {
        Write-SPMLog -Level WARNING -Operation 'Permissions' -Message (
            "Racines de bibliothèque non résolues : $(($_.Exception.Message -split [char]10)[0])"
        )
    }

    if (-not $srcRoot -or -not $dstRoot) {
        # Sans les deux racines, l'homologue ne peut être cherché que par son
        # nom. On refuse plutôt que d'écrire des droits sur un élément choisi
        # au hasard parmi ses homonymes.
        throw "Racine de bibliothèque irrésoluble (source : $srcRoot, destination : $dstRoot). " +
        'Les permissions ne peuvent pas être reportées sans pouvoir désigner l''élément homologue avec certitude.'
    }

    Write-SPMLog -Level INFO -Operation 'Permissions' -Message "Lecture des permissions de '$SourceList' sur $SourceSiteUrl"

    # Seuls les éléments dont l'héritage est rompu portent des permissions propres.
    $items = Invoke-SPMWithRetry -Operation 'Get-PnPListItem/perms' -ScriptBlock {
        Get-PnPListItem -List $SourceList -PageSize 500 -Fields 'FileLeafRef', 'FileRef', 'HasUniqueRoleAssignments' `
            -Connection $srcConn -ErrorAction Stop
    }

    $unique = @($items | Where-Object {
            $prop = $_.FieldValues['HasUniqueRoleAssignments']
            $prop -eq $true
        })

    Write-SPMLog -Level INFO -Operation 'Permissions' -Message (
        "$($unique.Count) élément(s) avec permissions uniques sur $(@($items).Count) au total"
    )

    $processed = 0
    $applied = 0
    $skipped = 0
    $failures = [System.Collections.Generic.List[object]]::new()

    $surplus = [System.Collections.Generic.List[object]]::new()

    foreach ($item in $unique) {
        $processed++
        $leaf = "$($item.FieldValues['FileLeafRef'])"
        $dossierSource = "$($item.FieldValues['FileDirRef'])".TrimEnd('/')

        $perms = Invoke-SPMWithRetry -Operation 'Get-PnPListItemPermission' -PassThruErrors -ScriptBlock {
            Get-PnPListItemPermission -List $SourceList -Identity $item.Id -Connection $srcConn -ErrorAction Stop
        }
        if (-not $perms) { $skipped++; continue }

        # Le dossier parent, transposé d'une bibliothèque à l'autre. C'est LUI
        # qui désigne l'élément : chercher « Rapport.docx » sans son dossier
        # dans une bibliothèque qui en compte dix rendait le premier venu.
        $dossierCible = $dstRoot
        if ($dossierSource.StartsWith($srcRoot, [StringComparison]::OrdinalIgnoreCase)) {
            $relatif = $dossierSource.Substring($srcRoot.Length).TrimStart('/')
            if ($relatif) { $dossierCible = "$dstRoot/$relatif" }
        }

        $targetItem = Get-SPMListItemByPath -List $TargetList -LeafName $leaf `
            -FolderServerRelativeUrl $dossierCible -Connection $dstConn
        if (-not $targetItem) {
            # Absent OU ambigu : dans les deux cas on ne sait pas sur quoi on
            # écrirait. C'est un ÉCHEC, pas un avertissement — appliquer des
            # droits au mauvais document est pire que ne pas les appliquer.
            $failures.Add([pscustomobject]@{
                    Item = "$dossierCible/$leaf"
                    Error = 'Élément homologue introuvable ou ambigu à destination : aucune permission appliquée'
                })
            continue
        }

        # ------------------------------------------------ héritage rompu
        # Ces éléments-là avaient des permissions PROPRES à la source. Les
        # ajouter à destination sans rompre l'héritage produisait les droits du
        # parent PLUS ceux de la source : une destination toujours plus
        # permissive que l'original.
        $heritageRompu = $false
        if ($PSCmdlet.ShouldProcess("$dossierCible/$leaf", "Rompre l'héritage des permissions")) {
            try {
                $idCible = $targetItem.Id
                Invoke-SPMWithRetry -Operation 'Set-PnPListItemPermission/inherit' -MaxRetries 1 -ScriptBlock {
                    Set-PnPListItemPermission -List $TargetList -Identity $idCible `
                        -InheritPermissions:$false -Connection $dstConn -ErrorAction Stop
                } | Out-Null
                $heritageRompu = $true
            }
            catch {
                $failures.Add([pscustomobject]@{
                        Item = "$dossierCible/$leaf"
                        Error = "Héritage non rompu : $(($_.Exception.Message -split [char]10)[0])"
                    })
                continue
            }
        }

        $attendus = @{}

        foreach ($entry in @($perms)) {
            $principal = $entry.PSObject.Properties['PrincipalName']
            $roles = $entry.PSObject.Properties['Permissions']
            if (-not $principal -or -not $roles) { continue }

            $target = Resolve-SPMIdentity -LoginName ([string]$principal.Value) -RequireMapping:$StrictIdentityMapping
            if (-not $target) {
                $skipped++
                Write-SPMLog -Level WARNING -Operation 'Permissions' -Message (
                    "Identité non résolue, permission non appliquée sur '$leaf' : $($principal.Value)"
                )
                continue
            }

            $rolesPropres = @(@($roles.Value) | ForEach-Object { "$_" } | Where-Object { $_ })
            if (-not $rolesPropres.Count) { continue }
            $attendus[$target] = $rolesPropres

            $premier = $true
            foreach ($roleName in $rolesPropres) {
                $describe = "$leaf : $target -> $roleName"
                if (-not $PSCmdlet.ShouldProcess($describe, 'Appliquer la permission')) { continue }

                try {
                    $splat = @{
                        List = $TargetList
                        Identity = $targetItem.Id
                        User = $target
                        AddRole = $roleName
                        Connection = $dstConn
                        ErrorAction = 'Stop'
                    }
                    # Remettre à plat sur le PREMIER rôle du principal : ce qu'il
                    # avait déjà à destination n'a pas à s'ajouter à ce que la
                    # source lui donnait.
                    if ($premier) { $splat.ClearExisting = $true }

                    Invoke-SPMWithRetry -Operation 'Set-PnPListItemPermission' -ScriptBlock {
                        Set-PnPListItemPermission @splat
                    } | Out-Null

                    $applied++
                    $premier = $false
                }
                catch {
                    $failures.Add([pscustomobject]@{ Item = $leaf; Principal = $target; Role = $roleName; Error = $_.Exception.Message })
                }
            }
        }

        # -------------------------------------- relecture et surplus de droit
        # « Set-PnPListItemPermission n'a pas échoué » ne dit rien de l'état
        # final. Un droit de plus qu'à la source est une FUITE, et c'est le seul
        # défaut de cette étape qui ne se voit jamais à l'œil nu.
        if ($heritageRompu -and $attendus.Count -and -not $WhatIfPreference) {
            $obtenus = Invoke-SPMWithRetry -Operation 'Get-PnPListItemPermission/verify' -PassThruErrors -ScriptBlock {
                Get-PnPListItemPermission -List $TargetList -Identity $targetItem.Id -Connection $dstConn -ErrorAction Stop
            }

            foreach ($entry in @($obtenus)) {
                $nom = $entry.PSObject.Properties['PrincipalName']
                $roles = $entry.PSObject.Properties['Permissions']
                if (-not $nom) { continue }

                $qui = "$($nom.Value)"
                $rolesObtenus = @(@($roles.Value) | ForEach-Object { "$_" } | Where-Object { $_ })

                if (-not $attendus.ContainsKey($qui)) {
                    $surplus.Add([pscustomobject]@{
                            Item = "$dossierCible/$leaf"; Principal = $qui
                            Detail = "présent à destination, absent de la source : $($rolesObtenus -join ', ')"
                        })
                    continue
                }

                $enTrop = @($rolesObtenus | Where-Object { $_ -notin $attendus[$qui] })
                if ($enTrop.Count) {
                    $surplus.Add([pscustomobject]@{
                            Item = "$dossierCible/$leaf"; Principal = $qui
                            Detail = "rôle(s) au-delà de la source : $($enTrop -join ', ')"
                        })
                }
            }
        }
    }

    # @() obligatoire : une identité non mappée revient en scalaire, et « .Count »
    # sur une chaîne échoue en mode strict. Le bilan tombait donc exactement dans
    # le cas où il avait le plus à dire.
    $unmapped = @(Get-SPMUnmappedIdentity)

    $niveau = if ($failures.Count -or $unmapped.Count -or $surplus.Count) { 'WARNING' } else { 'SUCCESS' }
    Write-SPMLog -Level $niveau -Operation 'Permissions' -Message (
        "Permissions : $applied appliquée(s), $skipped ignorée(s), $($failures.Count) échec(s), " +
        "$($unmapped.Count) identité(s) non mappée(s), $($surplus.Count) surplus de droit"
    ) -Data @{
        processed = $processed
        applied = $applied
        skipped = $skipped
        failures = $failures.Count
        unmapped = $unmapped.Count
        surplus = $surplus.Count
    }

    foreach ($s in $surplus) {
        Write-SPMLog -Level ERROR -Operation 'Permissions' -Message (
            "SURPLUS DE DROIT sur $($s.Item) — $($s.Principal) : $($s.Detail)"
        )
    }

    if ($unmapped.Count -gt 0) {
        Write-SPMLog -Level WARNING -Operation 'Permissions' -Message (
            "Identités sans correspondance : $($unmapped -join ', ')"
        )
    }

    [pscustomobject]@{
        # Un surplus de droit fait échouer l'étape : la destination est alors
        # plus permissive que la source, ce qui est le seul résultat pire qu'une
        # permission manquante.
        Ok = ($failures.Count -eq 0 -and $surplus.Count -eq 0)
        Processed = $processed
        Applied = $applied
        Skipped = $skipped
        Unmapped = $unmapped
        Failures = $failures.ToArray()
        Surplus = $surplus.ToArray()
    }
}