Chapitre 1222 minDébutant
Scripts Active Directory : créer des comptes en masse depuis un CSV
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- créer des comptes en masse depuis un CSV, avec simulation et journal ;
- calculer des logins uniques sans accents et gérer les homonymes ;
- désactiver proprement le compte d'un salarié qui part ;
- produire un rapport d'audit des comptes en HTML.
4 exercices corrigés1 schéma
Un script Active Directory automatise la gestion des comptes de l'annuaire avec le module PowerShell ActiveDirectory : création en masse à partir d'un fichier CSV, départs, rapports d'audit. Le cours Windows Server présente l'annuaire, les unités d'organisation, la méthode AGDLP et une première création de comptes par CSV. Ce chapitre construit la version qu'on peut confier à un collègue : paramètres validés, simulation avec -WhatIf, identifiants uniques, comptes temporaires qui expirent, journal et rapport, et un script de départ qui ne laisse aucun droit derrière lui.
Les prérequis
- Le module. Sur un contrôleur de domaine, il est installé avec le rôle. Sur le poste d'administration sous Windows 11, il vient des outils RSAT :
Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0. Sur un serveur membre :Install-WindowsFeature RSAT-AD-PowerShell. Le module se charge nativement dans PowerShell 7 comme dans Windows PowerShell 5.1. - Le compte. Le script ne se lance pas avec un compte Admins du domaine. On délègue à un groupe de techniciens le droit de créer et de modifier des comptes dans l'OU
Utilisateurs, et le script tourne avec le compte d'administration délégué du technicien (le principe du moindre privilège et la délégation sont décrits dans le cours Windows Server). - L'arborescence. Les OU de services existent sous
OU=Utilisateurs,OU=Sorbier,DC=ad,DC=sorbier,DC=example(Direction,Comptabilite,Commercial,Logistique,Informatique), ainsi que les groupes globaux des services :GG_Direction,GG_Comptables,GG_CommerciauxetGG_Logisticiens.
Le fichier d'entrée
Chaque année en novembre, l'entrepôt de Sorbier recrute des saisonniers pour le pic de fin d'année. Le service des ressources humaines envoie arrivees.csv, en UTF-8 avec des points-virgules ; la colonne DateFin, au format aaaa-mm-jj, n'est remplie que pour les contrats temporaires :
Prenom;Nom;Service;Fonction;DateFin
Théo;Marchal;Logistique;Préparateur;2027-01-08
Éloïse;Gaillard;Logistique;Préparatrice;2027-01-08
Inès;Roux;Comptabilite;Assistante comptable;
Zoë;N'Diaye;Logistique;Cariste;2027-01-08
Jean-Luc;Dupré;Comptabilite;Comptable;
Inès Roux, qui arrive à la comptabilité, est une homonyme : une autre Inès Roux travaille déjà au service commercial, avec le login iroux. Zoë N'Diaye a une apostrophe dans son nom. Ce sont exactement les cas qui cassent un script écrit trop vite.
Le déroulement du script
Le script complet
#Requires -Modules ActiveDirectory
<#
.SYNOPSIS
Crée les comptes Active Directory des nouveaux arrivants à partir d'un fichier CSV.
.DESCRIPTION
Colonnes : Prenom;Nom;Service;Fonction;DateFin (DateFin facultative, aaaa-mm-jj : dernier jour du contrat).
Une personne qui a déjà un compte dans l'OU de son service est ignorée : le script peut être relancé.
Les mots de passe initiaux sont écrits dans un fichier à remettre de façon sécurisée, puis à supprimer.
.EXAMPLE
.\New-ComptesArrivees.ps1 -CheminCsv .\arrivees.csv -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
[string]$CheminCsv,
[string]$Domaine = 'ad.sorbier.example',
[string]$BaseOU = 'OU=Utilisateurs,OU=Sorbier,DC=ad,DC=sorbier,DC=example',
[string]$DossierSortie = (Join-Path $PSScriptRoot 'sorties')
)
$ErrorActionPreference = 'Stop'
# Service du CSV -> groupe global du service (méthode AGDLP, cours Windows Server)
$groupes = @{
Direction = 'GG_Direction'
Comptabilite = 'GG_Comptables'
Commercial = 'GG_Commerciaux'
Logistique = 'GG_Logisticiens'
# Pas d'Informatique : ces comptes se créent à la main, leurs groupes donnent des droits délégués
}
function ConvertTo-Login {
param([string]$Prenom, [string]$Nom)
$brut = ($Prenom.Substring(0, 1) + $Nom).ToLower() -replace 'æ', 'ae' -replace 'œ', 'oe'
($brut.Normalize([Text.NormalizationForm]::FormD) -replace '\p{Mn}', '') -replace '[^a-z0-9]', ''
}
function New-MotDePasseInitial {
param([int]$Longueur = 16)
# 64 caractères sans les signes ambigus (I, O, l, o, 0, 1) : un octet modulo 64 ne favorise aucun caractère
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!#%+=?@-'
$octets = [byte[]]::new($Longueur)
$generateur = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$generateur.GetBytes($octets)
$generateur.Dispose()
-join ($octets | ForEach-Object { $alphabet[$_ % 64] })
}
# Le journal s'écrit même en simulation : -WhatIf:$false sur les commandes de fichiers
$null = New-Item -Path $DossierSortie -ItemType Directory -Force -WhatIf:$false
$horodatage = Get-Date -Format 'yyyy-MM-dd_HHmm'
$journal = Join-Path $DossierSortie "creation-$horodatage.log"
function Write-Journal {
param([string]$Message)
Add-Content -Path $journal -Value "$(Get-Date -Format s) $Message" -Encoding UTF8 -WhatIf:$false
}
$lignes = @(Import-Csv -Path $CheminCsv -Delimiter ';' -Encoding UTF8)
$manquantes = 'Prenom', 'Nom', 'Service', 'Fonction' |
Where-Object { $_ -notin $lignes[0].PSObject.Properties.Name }
if ($manquantes) { throw "Colonnes absentes du CSV : $($manquantes -join ', ')" }
$bilan = [System.Collections.Generic.List[object]]::new()
Write-Journal "Début : $($lignes.Count) ligne(s) dans $CheminCsv"
foreach ($l in $lignes) {
$prenom = $l.Prenom.Trim()
$nom = $l.Nom.Trim()
$service = $l.Service.Trim()
$ou = "OU=$service,$BaseOU"
if (-not $groupes.ContainsKey($service)) {
Write-Journal "IGNORÉ $prenom $nom : service inconnu « $service »"
Write-Warning "$prenom $nom : service inconnu « $service »"
continue
}
# Déjà créé lors d'un lancement précédent ? Les variables dans -Filter évitent le piège des apostrophes.
if (Get-ADUser -Filter 'GivenName -eq $prenom -and Surname -eq $nom' -SearchBase $ou) {
Write-Journal "IGNORÉ $prenom $nom : un compte existe déjà dans $ou"
continue
}
# Login unique : jdupre, sinon jdupre2, jdupre3…
$base = ConvertTo-Login -Prenom $prenom -Nom $nom
$login = $base
$n = 2
while (Get-ADUser -Filter 'SamAccountName -eq $login') {
$login = "$base$n"
$n++
}
$mdp = New-MotDePasseInitial
$params = @{
Name = if ($login -eq $base) { "$prenom $nom" } else { "$prenom $nom ($login)" }
GivenName = $prenom
Surname = $nom
DisplayName = "$prenom $nom"
SamAccountName = $login
UserPrincipalName = "$login@$Domaine"
Path = $ou
Department = $service
Title = $l.Fonction
AccountPassword = ConvertTo-SecureString -String $mdp -AsPlainText -Force
ChangePasswordAtLogon = $true
Enabled = $true
}
if ($l.DateFin) {
# Le compte expire à minuit au début du jour indiqué : on vise le lendemain du dernier jour
$params.AccountExpirationDate = [datetime]::ParseExact($l.DateFin, 'yyyy-MM-dd', $null).AddDays(1)
}
if ($PSCmdlet.ShouldProcess("$prenom $nom ($login) dans $ou", 'Créer le compte')) {
try {
New-ADUser @params
Add-ADGroupMember -Identity $groupes[$service] -Members $login
Write-Journal "CRÉÉ $login ($prenom $nom, $service)"
$bilan.Add([pscustomobject]@{
Login = $login; Nom = "$prenom $nom"; Service = $service; MotDePasseInitial = $mdp
})
}
catch {
Write-Journal "ERREUR $login : $($_.Exception.Message)"
Write-Warning "$login : $($_.Exception.Message)"
}
}
}
if ($bilan.Count -gt 0) {
$fichierMdp = Join-Path $DossierSortie "mots-de-passe-$horodatage.csv"
$bilan | Export-Csv -Path $fichierMdp -Delimiter ';' -NoTypeInformation -Encoding UTF8 -WhatIf:$false
Write-Output "$($bilan.Count) compte(s) créé(s). Mots de passe initiaux : $fichierMdp"
}
Write-Journal "Fin : $($bilan.Count) compte(s) créé(s)"
Ce qui fait la différence
La simulation. [CmdletBinding(SupportsShouldProcess)] donne au script le paramètre -WhatIf. On le lance toujours d'abord ainsi : .\New-ComptesArrivees.ps1 -CheminCsv .\arrivees.csv -WhatIf affiche une ligne « What if » par compte qui serait créé, avec son login et son OU. On relit, puis on relance sans -WhatIf. Attention, -WhatIf se propage à toutes les commandes du script qui le gèrent, y compris New-Item, Add-Content et Export-Csv : sans le -WhatIf:$false sur ces trois-là, la simulation n'écrirait même pas son journal.
Les identifiants. La conversion retire les accents (« Éloïse Gaillard » donne egaillard), les espaces, les tirets et les apostrophes (« Zoë N'Diaye » donne zndiaye, « Jean-Luc Dupré » donne jdupre). Les ligatures æ et œ ne se décomposent pas comme les lettres accentuées : sans les deux -replace qui les traitent à part, « Cœur » deviendrait cur. L'homonyme Inès Roux reçoit iroux2, et son nom complet devient Inès Roux (iroux2), ce qui distingue les deux comptes dans les recherches et les listes de membres des groupes.
Les filtres avec variables. -Filter "Surname -eq '$nom'" construit le filtre par concaténation : avec N'Diaye, l'apostrophe ferme la chaîne trop tôt et la requête échoue. Écrit entre apostrophes, -Filter 'Surname -eq $nom' laisse le module ActiveDirectory lire lui-même la variable, quelle que soit sa valeur.
La relance. Si le script s'arrête au milieu (contrôleur de domaine injoignable, fichier corrigé en cours de route), on le relance sur le même CSV : les personnes qui ont déjà leur compte dans l'OU de leur service sont ignorées. Un script qu'on peut relancer sans risque s'appelle idempotent. Revers de cette règle : un vrai homonyme arrivant dans le même service serait pris pour un compte déjà créé. Le journal le signale par une ligne « IGNORÉ … un compte existe déjà », que le technicien lit après chaque exécution avant de créer ce compte-là à la main.
L'expiration. AccountExpirationDate fixe l'instant où le compte cesse de fonctionner : avec 2027-01-08, ce serait le 8 janvier à 0 h, et le saisonnier ne pourrait pas travailler son dernier jour. Le script vise donc le lendemain. La console Utilisateurs et ordinateurs Active Directory affiche d'ailleurs « Fin de : 08/01/2027 » pour une expiration au 9 janvier à 0 h.
Les mots de passe. Le générateur cryptographique RandomNumberGenerator produit des mots de passe imprévisibles, contrairement à Get-Random. Le fichier mots-de-passe-….csv est le point faible du dispositif : il se remet au responsable par un canal sûr, et se supprime ensuite. Un mot de passe peut, très rarement, ne pas contenir trois types de caractères et être refusé par la stratégie de mots de passe du domaine : le catch le note dans le journal, et il suffit de relancer le script.
Le départ d'un salarié
Le jour du départ, un compte doit perdre immédiatement ses accès, mais pas disparaître : ses fichiers, ses courriels et les traces de ses actions doivent rester consultables. La procédure de Sorbier : désactiver, retirer des groupes (en gardant la liste pour pouvoir revenir en arrière), décrire, déplacer dans l'OU Departs.
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string[]]$Login,
[string]$OUDeparts = 'OU=Departs,OU=Sorbier,DC=ad,DC=sorbier,DC=example'
)
$ErrorActionPreference = 'Stop'
foreach ($l in $Login) {
try {
$u = Get-ADUser -Identity $l -Properties MemberOf
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {
Write-Warning "$l : compte introuvable"
continue
}
if ($PSCmdlet.ShouldProcess($u.SamAccountName, 'Désactiver, retirer des groupes, déplacer')) {
$groupesRetires = foreach ($g in $u.MemberOf) {
Remove-ADGroupMember -Identity $g -Members $u -Confirm:$false
($g -split ',')[0] -replace '^CN='
}
Disable-ADAccount -Identity $u
Set-ADUser -Identity $u -Description "Départ le $(Get-Date -Format 'dd/MM/yyyy'), traité par $env:USERNAME"
Move-ADObject -Identity $u.DistinguishedName -TargetPath $OUDeparts
[pscustomobject]@{ Login = $u.SamAccountName; GroupesRetires = $groupesRetires -join ', ' }
}
}
MemberOf ne contient pas le groupe principal (Utilisateurs du domaine), qui reste : c'est voulu, un compte doit avoir un groupe principal. Le déplacement vient en dernier, parce qu'il change le nom distinctif du compte. Le résultat, exporté en CSV, garde la trace des groupes retirés : si la personne revient, ou si le départ était une erreur, on sait quoi rétablir. Lancé chaque matin sur la liste des contrats terminés, ce script est aussi une mesure de sécurité : un compte actif sans titulaire est une porte ouverte.
Un rapport d'audit en HTML
Une fois par mois, Antoine Mercier veut voir les comptes à risque : actifs mais inutilisés depuis 90 jours, ou dont le mot de passe n'expire jamais. ConvertTo-Html produit une page lisible dans n'importe quel navigateur.
$base = 'OU=Utilisateurs,OU=Sorbier,DC=ad,DC=sorbier,DC=example'
$limite = (Get-Date).AddDays(-90)
$comptes = Get-ADUser -Filter 'Enabled -eq $true' -SearchBase $base `
-Properties LastLogonDate, PasswordNeverExpires, PasswordLastSet, whenCreated, Department
$inactifs = $comptes | Where-Object {
$reference = if ($_.LastLogonDate) { $_.LastLogonDate } else { $_.whenCreated }
$reference -lt $limite
}
$sansExpiration = $comptes | Where-Object PasswordNeverExpires
$corps = @(
$inactifs | Select-Object SamAccountName, Department, LastLogonDate |
ConvertTo-Html -Fragment -PreContent "<h2>Comptes inactifs depuis 90 jours ($(@($inactifs).Count))</h2>"
$sansExpiration | Select-Object SamAccountName, Department, PasswordLastSet |
ConvertTo-Html -Fragment -PreContent "<h2>Mots de passe qui n'expirent jamais ($(@($sansExpiration).Count))</h2>"
)
ConvertTo-Html -Title 'Audit des comptes Sorbier' -Body $corps -PostContent "<p>Généré le $(Get-Date -Format 'dd/MM/yyyy HH:mm')</p>" |
Out-File -FilePath "D:\Rapports\audit-comptes-$(Get-Date -Format 'yyyy-MM').html" -Encoding utf8
LastLogonDate est répliquée avec un retard volontaire de 9 à 14 jours : elle convient pour repérer une inactivité de 90 jours, pas pour savoir qui s'est connecté hier. La désactivation automatique des comptes inactifs est présentée dans le cours Windows Server. Pour envoyer le rapport par courriel, n'utilise pas Send-MailMessage : Microsoft la déclare obsolète car elle ne garantit pas une connexion sécurisée au serveur de messagerie. Dépose le fichier sur un partage, ou passe par l'API de la messagerie de l'entreprise.
Exercices
Exercice 1 sur 4 Calculer les logins Réussi À revoir
Avec la fonction ConvertTo-Login et la boucle d'unicité du script, quel login reçoit chaque personne ? Les comptes jlefevre, kmorel, pmartin et iroux existent déjà, et les lignes sont traitées dans l'ordre.
- Paul Le Gall
- Julie Lefèvre (une autre personne que la comptable actuelle)
- Hugo Lefèvre
- Lætitia Cœur
- Ömer Yılmaz
Voir la réponseMasquer la réponse de l'exercice 1
plegall: l'espace est supprimé.jlefevre2:jlefevreest déjà pris.hlefevre: l'initiale du prénom diffère, pas de conflit.lcoeur: la ligatureœest remplacée paroeavant la suppression des accents. Sans ce traitement, le résultat seraitlcur.oylmaz:Öse décompose enOplus un tréma, retiré ; leıturc (i sans point) n'est pas une lettre accentuée mais une lettre à part entière, qui n'a pas de décomposition et disparaît avec les caractères hors dea-z. Pour ces noms, la convention de nommage de l'entreprise doit prévoir une règle, ou une correction à la main du fichier.
Tu avais trouvé ?
Exercice 2 sur 4 Trois bugs dans un script de création Réussi À revoir
Ce script de création de comptes « marche sur les tests » mais pose trois problèmes en production. Trouve-les et corrige-les.
Import-Csv .\arrivees.csv -Delimiter ';' | ForEach-Object {
$login = ($_.Prenom[0] + $_.Nom).ToLower()
if (Get-ADUser -Filter "SamAccountName -eq '$login'") { return }
New-ADUser -Name "$($_.Prenom) $($_.Nom)" -SamAccountName $login `
-Path "OU=$($_.Service),OU=Utilisateurs,OU=Sorbier,DC=ad,DC=sorbier,DC=example" `
-AccountPassword (ConvertTo-SecureString 'Bienvenue2026!' -AsPlainText -Force) `
-AccountExpirationDate $_.DateFin -Enabled $true
}
Voir la réponseMasquer la réponse de l'exercice 2
- Le login n'est pas nettoyé. « Zoë N'Diaye » donne
zn'diaye, avec une apostrophe : le filtre"SamAccountName -eq '$login'"se ferme trop tôt etGet-ADUseréchoue. « Éloïse Gaillard » donneégaillard, avec un accent. Il faut la conversionConvertTo-Login(accents, ligatures, caractères horsa-z0-9) et un filtre avec variable :-Filter 'SamAccountName -eq $login'. - L'homonyme est ignoré en silence. Si
irouxexiste, la nouvelle Inès Roux n'a pas de compte et personne n'est prévenu. Il faut distinguer « cette personne a déjà son compte » (même prénom et nom dans l'OU de son service) de « ce login est pris par quelqu'un d'autre » (ajouter un chiffre), et journaliser chaque cas. - Le même mot de passe pour tous, écrit dans le script, sans changement obligatoire. Quiconque connaît le script connaît le mot de passe de tous les nouveaux comptes. Il faut un mot de passe aléatoire par compte et
-ChangePasswordAtLogon $true.
On peut ajouter : -AccountExpirationDate $_.DateFin reçoit une chaîne vide pour les contrats permanents (erreur de conversion) et, pour les autres, fait expirer le compte au début du dernier jour du contrat ; et sans SupportsShouldProcess, impossible de tester avec -WhatIf.
Tu avais trouvé ?
Exercice 3 sur 4 Le journal vide de la simulation Réussi À revoir
Un technicien a ajouté SupportsShouldProcess à son script pour pouvoir le tester avec -WhatIf. En simulation, les lignes « What if » s'affichent bien pour les comptes, mais aussi pour la création du dossier sorties et pour chaque écriture dans le journal, et le fichier journal n'existe pas après la simulation. Explique et corrige.
Voir la réponseMasquer la réponse de l'exercice 3
-WhatIf règle la variable $WhatIfPreference à $true dans tout le script : chaque commande qui gère la simulation (New-Item, Add-Content, Set-Content, Export-Csv, Remove-Item…) se contente alors d'afficher ce qu'elle ferait. Le journal fait partie du résultat attendu de la simulation : il faut forcer ces commandes-là à agir vraiment, avec -WhatIf:$false :
$null = New-Item -Path $DossierSortie -ItemType Directory -Force -WhatIf:$false
Add-Content -Path $journal -Value $ligne -Encoding UTF8 -WhatIf:$false
Les commandes de l'annuaire (New-ADUser, Add-ADGroupMember) restent protégées par ShouldProcess et ne s'exécutent pas en simulation.
Tu avais trouvé ?
Exercice 4 sur 4 Préparer la fin de saison Réussi À revoir
Le 11 janvier 2027, Chloé doit vérifier que tous les comptes des saisonniers de l'OU Logistique ont bien expiré, puis les traiter comme des départs. Écris la commande qui liste les comptes expirés de cette OU, puis celle qui leur applique le script de départ (enregistré sous Disable-ComptesDeparts.ps1), d'abord en simulation.
Voir la réponseMasquer la réponse de l'exercice 4
$ou = 'OU=Logistique,OU=Utilisateurs,OU=Sorbier,DC=ad,DC=sorbier,DC=example'
$expires = Search-ADAccount -AccountExpired -UsersOnly -SearchBase $ou
$expires | Select-Object SamAccountName, AccountExpirationDate
.\Disable-ComptesDeparts.ps1 -Login $expires.SamAccountName -WhatIf
.\Disable-ComptesDeparts.ps1 -Login $expires.SamAccountName
Search-ADAccount -AccountExpired trouve les comptes dont la date d'expiration est passée. $expires.SamAccountName donne la liste des logins (énumération des membres), passée au paramètre -Login de type [string[]]. Un compte expiré ne permet déjà plus d'ouvrir de session, mais il garde ses groupes : le script de départ les retire et range le compte dans l'OU Departs.
Tu avais trouvé ?