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