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