Automatiser l'eDiscovery Microsoft 365 avec PowerShell
Introduction
L'eDiscovery Microsoft 365 permet d'extraire le contenu d'une boîte mail ou d'un OneDrive à des fins légales,
d'archivage ou d'investigation. Via le portail Purview, l'opération est manuelle et chronophage. Le module
Faitza_Ediscovery.ps1 l'automatise de bout en bout : création du cas, lancement de la recherche,
attente de completion, déclenchement de l'export et téléchargement du résultat.
Toutes les opérations s'appuient sur les cmdlets du Security & Compliance Center PowerShell,
accessibles via Connect-IPPSSession. Ce n'est pas Exchange Online — c'est un endpoint distinct qui
nécessite des permissions spécifiques.
Le compte de service doit être membre du groupe de rôles eDiscovery Manager dans
Microsoft Purview. Le module ExchangeOnlineManagement (v3+) est requis pour
Connect-IPPSSession. Installez-le avec Install-Module ExchangeOnlineManagement.
# Connexion au Security & Compliance Center
# (distinct de Connect-ExchangeOnline)
Connect-IPPSSession -UserPrincipalName [email protected]
# Connexion non-interactive (certificat)
Connect-IPPSSession `
-AppId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-CertificateThumbprint "ABCDEF1234567890ABCDEF1234567890ABCDEF12" `
-Organization "contoso.onmicrosoft.com"
# Vérifier que les cmdlets Compliance sont disponibles
Get-Command -Noun ComplianceCase | Select-Object Name
Cas d'usage : départ collaborateur
Le scénario typique est le départ d'un collaborateur : avant de supprimer son compte, le département juridique ou RH demande une archive complète de sa boîte mail et/ou de son OneDrive. L'eDiscovery est le seul mécanisme M365 qui permet d'exporter en PST de façon légalement défendable, avec une chaîne de traçabilité (cas numéroté, horodatage, opérateur identifié).
Les étapes ne peuvent pas être parallélisées : chaque phase dépend de la précédente. Le workflow complet est : Créer le cas → Créer la recherche → Démarrer la recherche → Attendre la completion → Créer l'action d'export → Attendre l'export → Télécharger → Nettoyer.
Le module expose six fonctions qui correspondent exactement à ces étapes. Elles peuvent être appelées individuellement pour reprendre un workflow interrompu, ou enchaînées dans un script d'orchestration.
Export boîte mail — New-Faitza_Ediscovery_PST
New-Faitza_Ediscovery_PST crée un cas eDiscovery et une recherche de contenu
ciblant exclusivement la boîte mail d'un utilisateur. Elle retourne l'objet cas et l'objet
recherche pour les étapes suivantes.
function New-Faitza_Ediscovery_PST {
<#
.SYNOPSIS
Crée un cas eDiscovery et une recherche de contenu sur la boîte mail d'un utilisateur.
.PARAMETER UserUPN
UPN de l'utilisateur cible (ex: [email protected]).
.PARAMETER CasePrefix
Préfixe du nom du cas (ex: "DEPART"). Le cas sera nommé "DEPART-jean.dupont-20250528".
.OUTPUTS
PSCustomObject avec CaseName, SearchName, UserUPN.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$UserUPN,
[string]$CasePrefix = "EDISCOVERY",
[switch]$show
)
begin {
if ($show) { Write-Host "$($PSStyle.Background.Blue)>>> New-Faitza_Ediscovery_PST <<<$($PSStyle.Reset)" }
$dateStamp = Get-Date -Format "yyyyMMdd"
$userSlug = ($UserUPN.Split("@")[0]).ToLower()
$caseName = "$CasePrefix-$userSlug-$dateStamp"
$searchName = "Search-Mailbox-$userSlug-$dateStamp"
}
process {
if (-not $PSCmdlet.ShouldProcess($UserUPN, "Créer cas eDiscovery + recherche boîte mail")) { return }
try {
# 1. Créer le cas
Write-Verbose "Création du cas : $caseName"
$case = New-ComplianceCase -Name $caseName -CaseType eDiscovery
Write-Host "[OK] Cas créé : $caseName (Id: $($case.Identity))" -ForegroundColor Cyan
# 2. Créer la recherche de contenu sur la boîte mail
Write-Verbose "Création de la recherche : $searchName"
$search = New-ComplianceSearch `
-Name $searchName `
-Case $caseName `
-ExchangeLocation $UserUPN `
-AllowNotFoundExchangeLocationsEnabled $false `
-ContentMatchQuery "kind:email" # Tous les emails, sans filtre de date
Write-Host "[OK] Recherche créée : $searchName" -ForegroundColor Cyan
# 3. Démarrer la recherche
Write-Verbose "Démarrage de la recherche..."
Start-ComplianceSearch -Identity $searchName
Write-Host "[OK] Recherche démarrée." -ForegroundColor Green
return [PSCustomObject]@{
CaseName = $caseName
SearchName = $searchName
UserUPN = $UserUPN
StartedAt = (Get-Date -Format "o")
}
}
catch {
throw [System.Exception]::new("New-Faitza_Ediscovery_PST échoué : $_")
}
}
end {
if ($show) { Write-Host "$($PSStyle.Background.Blue)<<< New-Faitza_Ediscovery_PST >>>$($PSStyle.Reset)" }
}
}
# Appel
$ctx = New-Faitza_Ediscovery_PST -UserUPN "[email protected]" -CasePrefix "DEPART" -show
Le paramètre -ContentMatchQuery accepte la syntaxe KQL de Purview. Pour exporter
l'intégralité de la boîte sans filtre, laissez-le vide ou utilisez "kind:email".
Pour cibler une plage temporelle : "kind:email AND sent:2024-01-01..2025-01-01".
Export OneDrive — New-Faitza_Ediscovery_OneDrive
New-Faitza_Ediscovery_OneDrive cible le OneDrive de l'utilisateur plutôt que sa boîte mail.
La structure est identique, mais -SharePointLocation remplace -ExchangeLocation.
L'URL OneDrive suit le format https://tenant-my.sharepoint.com/personal/upn_sanitized.
function New-Faitza_Ediscovery_OneDrive {
<#
.SYNOPSIS
Crée un cas eDiscovery et une recherche de contenu sur le OneDrive d'un utilisateur.
.PARAMETER UserUPN
UPN de l'utilisateur cible.
.PARAMETER TenantName
Nom du tenant sans .onmicrosoft.com (ex: "contoso").
.PARAMETER CasePrefix
Préfixe du nom du cas.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$UserUPN,
[Parameter(Mandatory)][string]$TenantName,
[string]$CasePrefix = "EDISCOVERY",
[switch]$show
)
begin {
if ($show) { Write-Host "$($PSStyle.Background.Blue)>>> New-Faitza_Ediscovery_OneDrive <<<$($PSStyle.Reset)" }
$dateStamp = Get-Date -Format "yyyyMMdd"
$userSlug = ($UserUPN.Split("@")[0]).ToLower()
$caseName = "$CasePrefix-OD-$userSlug-$dateStamp"
$searchName = "Search-OneDrive-$userSlug-$dateStamp"
# Construire l'URL OneDrive : [email protected] → jean_dupont_contoso_com
$oneDriveSlug = $UserUPN -replace '[@.]', '_'
$oneDriveUrl = "https://$TenantName-my.sharepoint.com/personal/$oneDriveSlug"
}
process {
if (-not $PSCmdlet.ShouldProcess($UserUPN, "Créer cas eDiscovery + recherche OneDrive")) { return }
try {
Write-Verbose "URL OneDrive cible : $oneDriveUrl"
# Créer le cas
$case = New-ComplianceCase -Name $caseName -CaseType eDiscovery
Write-Host "[OK] Cas créé : $caseName" -ForegroundColor Cyan
# Créer la recherche sur SharePoint/OneDrive
$search = New-ComplianceSearch `
-Name $searchName `
-Case $caseName `
-SharePointLocation $oneDriveUrl `
-AllowNotFoundExchangeLocationsEnabled $false
# Démarrer immédiatement
Start-ComplianceSearch -Identity $searchName
Write-Host "[OK] Recherche OneDrive démarrée : $searchName" -ForegroundColor Green
return [PSCustomObject]@{
CaseName = $caseName
SearchName = $searchName
UserUPN = $UserUPN
OneDriveUrl = $oneDriveUrl
StartedAt = (Get-Date -Format "o")
}
}
catch {
throw [System.Exception]::new("New-Faitza_Ediscovery_OneDrive échoué : $_")
}
}
end {
if ($show) { Write-Host "$($PSStyle.Background.Blue)<<< New-Faitza_Ediscovery_OneDrive >>>$($PSStyle.Reset)" }
}
}
# Appel
$ctxOD = New-Faitza_Ediscovery_OneDrive `
-UserUPN "[email protected]" `
-TenantName "contoso" `
-CasePrefix "DEPART" `
-show
L'URL OneDrive générée doit correspondre exactement à l'URL réelle du site. Si l'UPN contient
des caractères spéciaux (tirets, points multiples), la sanitisation peut différer. Vérifiez
l'URL via Get-SPOSite -IncludePersonalSite $true -Filter "Owner -eq '$UserUPN'"
avant de l'utiliser dans la recherche.
Attente & polling — Wait-Faitza_Ediscovery_Case
Une recherche de contenu peut prendre de quelques secondes à plusieurs heures selon le volume de données.
Wait-Faitza_Ediscovery_Case boucle sur Get-ComplianceSearch jusqu'à ce que
le statut soit Completed. Elle lève une exception si le délai maximum est dépassé.
function Wait-Faitza_Ediscovery_Case {
<#
.SYNOPSIS
Attend la completion d'une recherche eDiscovery avec polling configurable.
.PARAMETER SearchName
Nom de la recherche de contenu (ComplianceSearch).
.PARAMETER PollIntervalSeconds
Intervalle entre deux vérifications de statut. Défaut : 30 secondes.
.PARAMETER TimeoutMinutes
Délai maximum d'attente avant exception. Défaut : 120 minutes.
.OUTPUTS
L'objet ComplianceSearch final (Completed).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$SearchName,
[int]$PollIntervalSeconds = 30,
[int]$TimeoutMinutes = 120,
[switch]$show
)
begin {
if ($show) { Write-Host "$($PSStyle.Background.Blue)>>> Wait-Faitza_Ediscovery_Case <<<$($PSStyle.Reset)" }
$deadline = (Get-Date).AddMinutes($TimeoutMinutes)
$iteration = 0
}
process {
Write-Verbose "Polling '$SearchName' toutes les $PollIntervalSeconds s (timeout: $TimeoutMinutes min)..."
while ((Get-Date) -lt $deadline) {
$iteration++
$search = Get-ComplianceSearch -Identity $SearchName -ErrorAction Stop
$status = $search.Status
$items = $search.Items
$sizeBytes = $search.Size
$sizeMb = if ($sizeBytes -gt 0) { [math]::Round($sizeBytes / 1MB, 1) } else { 0 }
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] Iteration $iteration — Statut : $status | Items : $items | Taille : $sizeMb MB" -ForegroundColor DarkCyan
if ($status -eq "Completed") {
Write-Host "[OK] Recherche terminée : $items éléments ($sizeMb MB)" -ForegroundColor Green
return $search
}
if ($status -in @("Failed", "Stopped")) {
throw [System.Exception]::new("La recherche '$SearchName' a échoué avec le statut : $status")
}
# Statuts intermédiaires attendus : Starting, InProgress, NotStarted
Start-Sleep -Seconds $PollIntervalSeconds
}
throw [System.TimeoutException]::new(
"Timeout : la recherche '$SearchName' n'a pas terminé dans les $TimeoutMinutes minutes imparties."
)
}
end {
if ($show) { Write-Host "$($PSStyle.Background.Blue)<<< Wait-Faitza_Ediscovery_Case >>>$($PSStyle.Reset)" }
}
}
# Attendre la completion avec un polling toutes les 60 secondes
$completedSearch = Wait-Faitza_Ediscovery_Case `
-SearchName "Search-Mailbox-jean.dupont-20250528" `
-PollIntervalSeconds 60 `
-TimeoutMinutes 180 `
-show
Les valeurs de Status retournées par Get-ComplianceSearch sont :
NotStarted, Starting, InProgress, Completed,
Failed, Stopped. Seul Completed garantit que l'export
peut être déclenché sans erreur.
Déclencher & télécharger — Export-Faitza_Ediscovery_Case & Save-Faitza_Ediscovery_Case
Une fois la recherche terminée, Export-Faitza_Ediscovery_Case crée l'action d'export
(format PST ou fichier individuel). Save-Faitza_Ediscovery_Case attend la fin de
la préparation de l'export et récupère le lien de téléchargement.
function Export-Faitza_Ediscovery_Case {
<#
.SYNOPSIS
Déclenche l'action d'export sur une recherche eDiscovery terminée.
.PARAMETER SearchName
Nom de la ComplianceSearch (statut Completed requis).
.PARAMETER ExportFormat
FitIntoOnePST (un seul PST) ou IndividualMessage (fichiers .msg séparés).
Défaut : FitIntoOnePST.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$SearchName,
[ValidateSet("FitIntoOnePST", "PerSourcePST", "IndividualMessage")]
[string]$ExportFormat = "FitIntoOnePST",
[switch]$show
)
begin {
if ($show) { Write-Host "$($PSStyle.Background.Blue)>>> Export-Faitza_Ediscovery_Case <<<$($PSStyle.Reset)" }
$actionName = "Export-$SearchName"
}
process {
if (-not $PSCmdlet.ShouldProcess($SearchName, "Créer action d'export eDiscovery")) { return }
try {
$action = New-ComplianceSearchAction `
-SearchName $SearchName `
-Export `
-Format $ExportFormat `
-ExchangeArchiveFormat $ExportFormat `
-Scope IndexedItemsOnly # Exclure les éléments non indexés (corrompu, chiffré)
Write-Host "[OK] Action d'export créée : $($action.Name)" -ForegroundColor Cyan
return [PSCustomObject]@{
ActionName = $action.Name
SearchName = $SearchName
Format = $ExportFormat
CreatedAt = (Get-Date -Format "o")
}
}
catch {
throw [System.Exception]::new("Export-Faitza_Ediscovery_Case échoué : $_")
}
}
end {
if ($show) { Write-Host "$($PSStyle.Background.Blue)<<< Export-Faitza_Ediscovery_Case >>>$($PSStyle.Reset)" }
}
}
function Save-Faitza_Ediscovery_Case {
<#
.SYNOPSIS
Attend la completion de l'export et retourne l'URL + la clé de téléchargement.
.PARAMETER ActionName
Nom de la ComplianceSearchAction d'export.
.PARAMETER PollIntervalSeconds
Intervalle de polling. Défaut : 30 s.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ActionName,
[int]$PollIntervalSeconds = 30,
[int]$TimeoutMinutes = 60,
[switch]$show
)
begin {
if ($show) { Write-Host "$($PSStyle.Background.Blue)>>> Save-Faitza_Ediscovery_Case <<<$($PSStyle.Reset)" }
$deadline = (Get-Date).AddMinutes($TimeoutMinutes)
}
process {
while ((Get-Date) -lt $deadline) {
$action = Get-ComplianceSearchAction -Identity $ActionName -ErrorAction Stop
$status = $action.Status
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] Export statut : $status" -ForegroundColor DarkCyan
if ($status -eq "Completed") {
# Extraire l'URL et la clé SAS depuis les résultats de l'action
$results = $action.Results
$exportUrl = ($results -split ";") |
Where-Object { $_ -match "Container url:" } |
ForEach-Object { ($_ -split "Container url:")[1].Trim() }
$sasKey = ($results -split ";") |
Where-Object { $_ -match "SAS token:" } |
ForEach-Object { ($_ -split "SAS token:")[1].Trim() }
Write-Host "[OK] Export prêt au téléchargement." -ForegroundColor Green
Write-Host " URL : $exportUrl" -ForegroundColor Gray
return [PSCustomObject]@{
ActionName = $ActionName
ExportUrl = $exportUrl
SasToken = $sasKey
CompletedAt = (Get-Date -Format "o")
}
}
if ($status -in @("Failed", "Stopped")) {
throw [System.Exception]::new("L'export '$ActionName' a échoué : $status")
}
Start-Sleep -Seconds $PollIntervalSeconds
}
throw [System.TimeoutException]::new("Timeout : l'export '$ActionName' n'a pas terminé dans les $TimeoutMinutes minutes.")
}
end {
if ($show) { Write-Host "$($PSStyle.Background.Blue)<<< Save-Faitza_Ediscovery_Case >>>$($PSStyle.Reset)" }
}
}
# Workflow complet — étapes 4 et 5
$exportCtx = Export-Faitza_Ediscovery_Case -SearchName "Search-Mailbox-jean.dupont-20250528" -show
$dlInfo = Save-Faitza_Ediscovery_Case -ActionName $exportCtx.ActionName -show
# À ce stade, $dlInfo.ExportUrl et $dlInfo.SasToken permettent de télécharger le PST
# via l'outil Microsoft eDiscovery Export Tool (GUI) ou via AzCopy :
# azcopy copy "$($dlInfo.ExportUrl)?$($dlInfo.SasToken)" "C:\Exports\" --recursive
Le téléchargement du PST final nécessite l'eDiscovery Export Tool, un client Windows
téléchargeable depuis le portail Purview. Il ne peut pas être invoqué en ligne de commande pure.
L'alternative scriptable est d'utiliser AzCopy avec l'URL SAS retournée par
Get-ComplianceSearchAction, mais cette URL expire après 7 jours.
Nettoyage — Remove-Faitza_Ediscovery_Case
Après téléchargement, le cas doit être fermé et supprimé. Un cas eDiscovery actif maintient un hold
implicite sur les ressources Purview. Remove-Faitza_Ediscovery_Case supprime d'abord
la recherche et l'action d'export associées, puis ferme et supprime le cas.
function Remove-Faitza_Ediscovery_Case {
<#
.SYNOPSIS
Supprime un cas eDiscovery et tous ses artefacts associés (recherche, actions d'export).
.PARAMETER CaseName
Nom du cas eDiscovery à supprimer.
.PARAMETER SearchName
Nom de la ComplianceSearch associée.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")]
param(
[Parameter(Mandatory)][string]$CaseName,
[Parameter(Mandatory)][string]$SearchName,
[switch]$show
)
begin {
if ($show) { Write-Host "$($PSStyle.Background.Blue)>>> Remove-Faitza_Ediscovery_Case <<<$($PSStyle.Reset)" }
}
process {
if (-not $PSCmdlet.ShouldProcess($CaseName, "Supprimer cas eDiscovery et artefacts")) { return }
try {
# 1. Supprimer les actions d'export liées à la recherche
$actions = Get-ComplianceSearchAction -SearchName $SearchName -ErrorAction SilentlyContinue
foreach ($action in $actions) {
Write-Verbose "Suppression de l'action d'export : $($action.Name)"
Remove-ComplianceSearchAction -Identity $action.Name -Confirm:$false
Write-Host " [OK] Action supprimée : $($action.Name)" -ForegroundColor Gray
}
# 2. Supprimer la recherche de contenu
Write-Verbose "Suppression de la recherche : $SearchName"
Remove-ComplianceSearch -Identity $SearchName -Confirm:$false
Write-Host " [OK] Recherche supprimée : $SearchName" -ForegroundColor Gray
# 3. Fermer le cas (Status → Closed) avant de le supprimer
Set-ComplianceCase -Identity $CaseName -Status Closed
Write-Host " [OK] Cas fermé : $CaseName" -ForegroundColor Gray
# 4. Supprimer le cas
Remove-ComplianceCase -Identity $CaseName -Confirm:$false
Write-Host "[OK] Cas eDiscovery supprimé : $CaseName" -ForegroundColor Green
}
catch {
throw [System.Exception]::new("Remove-Faitza_Ediscovery_Case échoué : $_")
}
}
end {
if ($show) { Write-Host "$($PSStyle.Background.Blue)<<< Remove-Faitza_Ediscovery_Case >>>$($PSStyle.Reset)" }
}
}
# Nettoyage
Remove-Faitza_Ediscovery_Case `
-CaseName "DEPART-jean.dupont-20250528" `
-SearchName "Search-Mailbox-jean.dupont-20250528" `
-Confirm:$false `
-show
Pièges classiques
# ─── Piège 1 : Connect-ExchangeOnline ≠ Connect-IPPSSession ──────────────────
# Les cmdlets New-ComplianceCase, New-ComplianceSearch etc. ne sont disponibles
# qu'après Connect-IPPSSession. Connect-ExchangeOnline ne suffit pas.
Connect-IPPSSession -Organization "contoso.onmicrosoft.com"
# ─── Piège 2 : Lancer l'export avant Completed ───────────────────────────────
# New-ComplianceSearchAction sur une recherche en statut InProgress renvoie :
# "The search is not in a state that allows export."
# Toujours appeler Wait-Faitza_Ediscovery_Case avant Export-Faitza_Ediscovery_Case.
# ─── Piège 3 : Noms de cas dupliqués ─────────────────────────────────────────
# Les noms de cas doivent être uniques dans le tenant. Si tu relances le script
# le même jour pour le même utilisateur, le nom sera identique et New-ComplianceCase
# échoue avec "The name is already in use." Ajoutez l'heure ou un GUID au suffix.
$caseName = "DEPART-$userSlug-$(Get-Date -Format 'yyyyMMdd-HHmm')"
# ─── Piège 4 : Scope des éléments non indexés ────────────────────────────────
# Par défaut, New-ComplianceSearchAction -Export inclut les éléments non indexés
# (fichiers chiffrés, corrompus). Cela peut faire échouer le package PST.
# Utilisez -Scope IndexedItemsOnly sauf besoin légal spécifique.
# ─── Piège 5 : URL SAS expirée ───────────────────────────────────────────────
# L'URL de téléchargement retournée par Get-ComplianceSearchAction expire après
# 7 jours. Passé ce délai, il faut recréer l'action d'export (sans recréer le cas).
$expiredAction = "Export-Search-Mailbox-jean.dupont-20250528_Export"
Remove-ComplianceSearchAction -Identity $expiredAction -Confirm:$false
New-ComplianceSearchAction -SearchName "Search-Mailbox-jean.dupont-20250528" -Export -Format FitIntoOnePST
# ─── Piège 6 : Fermer le cas avant de le supprimer ───────────────────────────
# Remove-ComplianceCase sur un cas en statut "Active" échoue.
# Il faut d'abord Set-ComplianceCase -Status Closed, attendre quelques secondes,
# puis Remove-ComplianceCase.
Set-ComplianceCase -Identity $caseName -Status Closed
Start-Sleep -Seconds 5
Remove-ComplianceCase -Identity $caseName -Confirm:$false
Le compte exécutant ces cmdlets doit être membre du groupe de rôles eDiscovery Manager
dans le portail Microsoft Purview. Pour les exports, le rôle Compliance Administrator
peut être nécessaire selon la configuration du tenant. Vérifiez avec
Get-RoleGroupMember -Identity "eDiscovery Manager" depuis une session IPPSSession.
Immédiatement après New-ComplianceCase, les cmdlets New-ComplianceSearch
peuvent renvoyer une erreur indiquant que le cas n'existe pas encore. C'est un problème de propagation
dans l'infrastructure Purview. Ajoutez un Start-Sleep -Seconds 10 entre la création du cas
et la création de la recherche si vous rencontrez ce comportement.
// Commentaires
Aucun commentaire pour l'instant.