Private/Identity.ps1
|
<#
Correspondance d'identités entre source et destination. Indispensable dès qu'on migre entre deux tenants : le principal `i:0#.f|membership|jean@ancien.com` n'existe pas chez `nouveau.com`. Sans table de correspondance, toute tentative de restaurer les permissions échoue silencieusement ou attribue les droits au mauvais compte. La logique est volontairement pure (aucun appel réseau) : elle se teste intégralement hors connexion. #> $script:SPMIdentityMap = @{} $script:SPMIdentityUnmapped = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase) function ConvertTo-SPMLoginName { <# .SYNOPSIS Normalise un principal SharePoint en une clé comparable. .DESCRIPTION SharePoint expose la même identité sous plusieurs formes : i:0#.f|membership|jean@contoso.com jean@contoso.com c:0t.c|tenant|<guid> (groupe de sécurité) On extrait la partie signifiante et on la met en minuscules. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][AllowEmptyString()][string]$LoginName) if ([string]::IsNullOrWhiteSpace($LoginName)) { return '' } $value = $LoginName.Trim() # Revendication de type membership : la dernière partie porte l'UPN if ($value -like 'i:0#.f|membership|*') { return $value.Substring($value.LastIndexOf('|') + 1).ToLowerInvariant() } # Autres revendications (groupes, applications) : on conserve la forme complète, # elle est la seule à identifier le principal de façon fiable. if ($value.Contains('|')) { return $value.ToLowerInvariant() } return $value.ToLowerInvariant() } function Import-SPMIdentityMap { <# .SYNOPSIS Charge une table de correspondance d'identités depuis un CSV. .DESCRIPTION Format attendu — deux colonnes : SourceLogin,TargetLogin jean@ancien.com,jean.dupont@nouveau.com equipe-rh@ancien.com,rh@nouveau.com Une ligne dont TargetLogin est vide marque l'identité comme volontairement non migrée : elle est ignorée sans avertissement. .PARAMETER Path Chemin du fichier CSV. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string]$Path, [switch]$Append ) if (-not (Test-Path -LiteralPath $Path)) { throw "Table de correspondance introuvable : $Path" } if (-not $Append) { $script:SPMIdentityMap = @{} } $rows = Import-Csv -LiteralPath $Path -Encoding utf8 $columns = if ($rows.Count -gt 0) { $rows[0].PSObject.Properties.Name } else { @() } foreach ($required in 'SourceLogin', 'TargetLogin') { if ($required -notin $columns) { throw "Colonne '$required' absente de $Path (colonnes trouvées : $($columns -join ', '))" } } $loaded = 0 $ignored = 0 foreach ($row in $rows) { $key = ConvertTo-SPMLoginName -LoginName $row.SourceLogin if (-not $key) { continue } if ([string]::IsNullOrWhiteSpace($row.TargetLogin)) { $script:SPMIdentityMap[$key] = $null # exclusion explicite $ignored++ } else { $script:SPMIdentityMap[$key] = $row.TargetLogin.Trim() $loaded++ } } Write-SPMLog -Level SUCCESS -Operation 'Identity' -Message ( "Correspondance chargée : $loaded identité(s) mappée(s), $ignored exclue(s) volontairement" ) [pscustomobject]@{ Mapped = $loaded; Excluded = $ignored; Total = $script:SPMIdentityMap.Count } } function Resolve-SPMIdentity { <# .SYNOPSIS Traduit un principal source en principal destination. .DESCRIPTION Retourne : - le principal cible si une correspondance existe - $null si l'identité est explicitement exclue - le principal source inchangé si aucune table n'est chargée (cas d'une migration intra-tenant, où les identités sont identiques) Les identités non résolues sont mémorisées afin d'être rapportées en fin d'exécution : une permission silencieusement perdue est pire qu'une permission refusée bruyamment. #> # (voir Get-SPMPrincipalClass plus bas pour le traitement des principaux système) [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][AllowEmptyString()][string]$LoginName, [switch]$RequireMapping ) # Un principal système ne se transporte pas : le service en génère un # localement, il n'a pas d'existence hors de son tenant d'origine. if ((Get-SPMPrincipalClass -Principal $LoginName) -eq 'System') { return $null } $key = ConvertTo-SPMLoginName -LoginName $LoginName if (-not $key) { return $null } if ($script:SPMIdentityMap.Count -eq 0) { if ($RequireMapping) { $null = $script:SPMIdentityUnmapped.Add($LoginName) return $null } return $LoginName # migration intra-tenant : identités inchangées } if ($script:SPMIdentityMap.ContainsKey($key)) { return $script:SPMIdentityMap[$key] # peut être $null (exclusion voulue) } $null = $script:SPMIdentityUnmapped.Add($LoginName) return $(if ($RequireMapping) { $null } else { $LoginName }) } function Get-SPMUnmappedIdentity { <# .SYNOPSIS Retourne les identités rencontrées sans correspondance. #> [CmdletBinding()] [OutputType([string[]])] param([switch]$Reset) $list = @($script:SPMIdentityUnmapped) if ($Reset) { $script:SPMIdentityUnmapped.Clear() } return $list } function Clear-SPMIdentityMap { [CmdletBinding()] param() $script:SPMIdentityMap = @{} $script:SPMIdentityUnmapped.Clear() } function Get-SPMPrincipalClass { <# .SYNOPSIS Classe un principal : User, Group ou System. .DESCRIPTION La distinction n'est pas cosmétique. Un principal SYSTÈME — « Application SharePoint », app@sharepoint, le compte de flux de travail — est généré par le service dans son propre tenant. Il n'a pas d'existence à destination, et toute tentative de l'écrire comme Author ou Editor échoue. Sans cette classification, un tel principal partait à l'écriture avec les trois autres champs d'authorship et les faisait tous échouer : les dates d'origine étaient perdues à cause d'un auteur qui n'aurait de toute façon jamais pu être restauré. Accepte une chaîne (nom de connexion) ou un objet de champ utilisateur. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Principal) if ($null -eq $Principal) { return 'Unknown' } # Rassembler les représentations disponibles sans supposer laquelle existe : # StrictMode fait échouer l'accès à une propriété absente. $texte = if ($Principal -is [string]) { $Principal } else { $p = $Principal.PSObject.Properties @( if ($p['LoginName']) { $Principal.LoginName } if ($p['Email']) { $Principal.Email } if ($p['LookupValue']) { $Principal.LookupValue } if ($p['Title']) { $Principal.Title } ) -join ' ' } if ([string]::IsNullOrWhiteSpace($texte)) { return 'Unknown' } # Principaux générés par le service, en anglais et en français. $motifsSysteme = @( 'app@sharepoint' 'SHAREPOINT\\system' 'Application SharePoint' 'SharePoint App' 'System Account' 'Compte système' 'spocrwl' # crawler de recherche 'c:0\(\.s\|true' # « Tout le monde », principal spécial 'Workflow on behalf' ) foreach ($motif in $motifsSysteme) { if ($texte -match $motif) { return 'System' } } # Revendication de groupe : c:0t.c, c:0o.c (groupe M365), c:0-.f (rôle fédéré). if ($texte -match '^c:0[to\-]\.') { return 'Group' } return 'User' } |