TL;DR:
Script en PowerShell pour optimiser la récupération dans une variable en mémoire d'un grand nombre d'objets contenus dans un annuaire AD.
Concepts & outils:
PowerShell, Active Directory, LDAP
En PowerShell, on veut récupérer dans des variables en RAM (hash tables) tous les objets user, group et computer d'un domaine AD en un minimum de requêtes LDAP et sans faire fumer un DC plus que nécessaire.
On sait déjà faire naïvement:
Get-ADUser -filter * -properties *
Mais sur un AD d'un million d'objets, ... c'est long (voire ça ne peut pas se terminer correctement).
On présente ici une solution consistant à découper cela en plusieurs requêtes LDAP afin de charger en mémoire toutes les données utiles en un minimum de temps.
En PowerShell:
D'abord, on spécifie les attributs à récupérer, même s'il y en a beaucoup, plutôt que de faire bêtement "*". Par exemple
$Properties = @("MemberOf", "Manager", "PasswordLastSet", "pwdLastSet", "PasswordNeverExpires", "PasswordNotRequired", "c", "CN", "co", "Company", "Country", "DisplayName", "EmailAddress", "EmployeeID", "GivenName", "mail", "Name", "sn", "Surname", "UserPrincipalName", "ServicePrincipalNames", "DoesNotRequirePreAuth", "PrimaryGroupID", "PasswordExpired")
Récupérer dans une variable la liste de tous les objets:
$users = Get-ADObject -Filter {ObjectClass -eq "user" -and ObjectClass -ne "computer"} -Properties DistinguishedName -Server $DOMAIN
$nbusers = $users.count
Un Get-ADObject est beaucoup plus rapide qu'un Get-ADUser car cette dernière commande est une généralisation de Get-ADObject qui effectue davantage de travail. Get-ADObject effectue moins de conversion d'attributs, crée des objets .NET plus simples, expose moins de propriétés calculées, et récupère souvent moins d'attributs LDAP.
Extraire la liste des premiers caractères pour tous les objets:
$chars = [char[]]::new($users.Count)
$total = $users.Count
$i = 0
foreach ($item in $users) {
$dn = $item.DistinguishedName
$s = $dn.Substring(3) # enlève "CN="
if ($s.Length -gt 0) {
$chars[$i] = [char]::ToLowerInvariant($s[0])
} else {
$chars[$i] = [char]0
}
$i++
}
$uchars = $chars | sort -unique
$nbchars = $uchars.count
$uchars contient la liste des premiers caractères.
On peut maintenant scinder la collecte globale en autant de requêtes que de premiers caractères différents existants, en faisant une boucle:
$users = @{}
$iteration = 0
foreach ($char in $uchars) {
$percent = [int]((++$iteration) * 100 / $nbchars)
Write-Progress -Activity "Building final users hashtable" -PercentComplete $percent -Status "$percent %"
Get-ADUser -Server $DOMAIN -Filter "SamAccountName -like '$char*'" -Properties $Properties | ForEach-Object { $users[$_.DistinguishedName] = $_ }
}
Bien sûr si tous les comptes commencent par le même caractère, ça ne marche plus, il faut changer le critère de séparation des objets, mais le principe reste le même.
Compter les objets
$users.count
Afficher les clés
$users.keys
Construire un index des clés
$index = @()
$users.Keys | ForEach-Object { $index += $_ }
Lister les clés
$index
Interroger un objet
$users["CN=user1234,OU=PARIS,OU=EMEA,OU=CONTOSO,DC=LAB,DC=LOCAL"]
Interroger une properties d'un objet
($users["CN=user1234,OU=PARIS,OU=EMEA,OU=CONTOSO,DC=LAB,DC=LOCAL"]).samaccountname
Voici un exemple complet de script pouvant récupérer tous les users dans un domaine AD en un temps raisonnable.
$DOMAIN = "lab.local"
## Attributs à récupérer
$Properties = @("MemberOf", "Manager", "PasswordLastSet", "pwdLastSet", "PasswordNeverExpires", "PasswordNotRequired", "c", "CN", "co", "Company", "Country", "DisplayName", "EmailAddress", "EmployeeID", "GivenName", "mail", "Name", "sn", "Surname", "UserPrincipalName", "ServicePrincipalNames", "DoesNotRequirePreAuth", "PrimaryGroupID", "PasswordExpired")
## 1/ Récupération de la liste des users via Get-ADObject
Write-Host "> Getting all user objects from $DOMAIN..."
$ts = Measure-Command {
$users = Get-ADObject -Filter {ObjectClass -eq "user" -and ObjectClass -ne "computer"} -Properties DistinguishedName -Server $DOMAIN
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
$nbusers = $users.count
Write-Host "> $DOMAIN contains $nbusers users."
## 2/ Extraction de la liste des 1ers caractères
Write-Host "> Sorting objects by first character..."
$ts = Measure-Command {
$chars = [char[]]::new($users.Count)
$total = $users.Count
$i = 0
foreach ($item in $users) {
$dn = $item.DistinguishedName
$s = $dn.Substring(3) # enlève "CN="
if ($s.Length -gt 0) {
$chars[$i] = [char]::ToLowerInvariant($s[0])
} else {
$chars[$i] = [char]0
}
$i++
}
$uchars = $chars | sort -unique
$nbchars = $uchars.count
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
Write-Host "> Dividing query in $nbchars chunks."
## 3/ Récupération de la liste des users via Get-ADUser
$ts = Measure-Command {
Write-Host "> Gathering data..."
$users = @{}
$iteration = 0
foreach ($char in $uchars) {
$percent = [int]((++$iteration) * 100 / $nbchars)
Write-Progress -Activity "Building final users hashtable" -PercentComplete $percent -Status "$percent %"
Get-ADUser -Server $DOMAIN -Filter "SamAccountName -like '$char*'" -Properties $Properties | ForEach-Object { $users[$_.DistinguishedName] = $_ }
}
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
Voici un exemple concret de script capable de chercher tous les domaines AD connus et de "dumper" leur contenu. On se base sur le principe exposé précédemment et on récupère:
<###############################################################################
Recup-All.ps1
- récupération de la liste de tous les domaines connus et trustés du domaine
courant
- test de tous les domaines de la liste pour voir s'ils sont requêtables
- pour chaque domaine joignable et requêtable, récupération de tous les
users, groups, computers
- stockage de toutes les infos récupérées dans des fichiers
###############################################################################>
## Définir les types d'objets à extraire
$ObjectTypes = @("user", "group", "computer", "organizationalUnit", "contact")
## What to do
$GET_user = $True
$GET_group = $True
$GET_computer = $True
$GET_organizationalUnit = $True
$GET_contact = $True
## Nb. max d'objets pour des requêtes AD simples
$LIMIT_SINGLEQUERY = 5000
## Attributs à récupérer pour chaque type d'objet
$Properties_user = @("ObjectGUID", "SID", "SamAccountName", "CN", "CanonicalName", "DistinguishedName", "Name", "Enabled", "co", "Company", "Country", "DisplayName", "DoesNotRequirePreAuth", "EmailAddress", "EmployeeID", "GivenName", "mail", "Manager", "MemberOf", "PasswordExpired", "PasswordLastSet", "PasswordNeverExpires", "PasswordNotRequired", "PrimaryGroupID", "pwdLastSet", "ServicePrincipalNames", "Surname", "UserPrincipalName")
$Properties_group = @("ObjectGUID", "SID", "SamAccountName", "CN", "CanonicalName", "DistinguishedName", "Name", "GroupCategory", "GroupScope", "ManagedBy", "MemberOf", "Members")
$Properties_computer = @("ObjectGUID", "SID", "SamAccountName", "CN", "CanonicalName", "DistinguishedName", "Name", "Enabled", "DNSHostName", "OperatingSystem", "OperatingSystemVersion", "PasswordExpired")
$Properties_contact = @("ObjectGUID", "CN", "CanonicalName", "DistinguishedName", "Name")
$Properties_organizationalUnit = @("ObjectGUID", "CN", "CanonicalName", "DistinguishedName", "Name", "ProtectedFromAccidentalDeletion")
<###############################################################################
Liste des Domaines
###############################################################################>
## Fonction de récupération des domaines connus de l'infra AD
function Get-AllADDomains {
param(
[string]$RootDomain = (Get-ADDomain).DNSRoot,
[hashtable]$Seen = @{}
)
if ($Seen.ContainsKey($RootDomain)) { return }
$Seen[$RootDomain] = $true
try {
Write-Host "[Discovery] Browsing $RootDomain ..."
$trusts = Get-ADTrust -Server $RootDomain -Filter * -ErrorAction Stop
foreach ($t in $trusts) {
$target = $t.Target
if (-not $Seen.ContainsKey($target)) {
Get-AllADDomains -RootDomain $target -Seen $Seen
}
}
} catch {
Write-Host "(Break)"
}
return $Seen
}
## Récupération des domaines via la fonction Get-AllADDomains
Write-Host ""
Write-Host "Getting all discoverable AD domains."
$domains = Get-AllADDomains
$domainslist = $domains.Keys | sort -Unique
Write-Host ""
Write-Host "Raw AD domains list:"
$domainslist
## Validation des domaines / suppression des domaines non joignables
Write-Host ""
Write-Host "Now validating queryable domains."
$ok = @()
$timeout = 30
foreach ($domain in $domainslist) {
Write-Host "Check: $domain ..."
$job = Start-Job -ScriptBlock {
param($domain)
try {
Get-ADUser -Server $domain -Filter * -ResultSetSize 1 -ErrorAction Stop | Out-Null
return $true
} catch {
return $false
}
} -ArgumentList $domain
if (Wait-Job $job -Timeout $timeout) {
$result = Receive-Job $job
if ($result) { $ok += $domain }
}
Remove-Job $job -Force
}
$domains = $ok
Write-Host ""
Write-Host "Final AD domains list:"
$domains
## Save domains list
$domains | Out-File .\domains.txt
<###############################################################################
Fonction pour supprimer les caractères accentués de $uchars
###############################################################################>
function Remove-AccentDuplicates {
param(
[Parameter(Mandatory)]
[string[]]$Chars
)
$normalized = @{}
foreach ($c in $Chars) {
$decomposed = $c.Normalize([System.Text.NormalizationForm]::FormD)
$plain = -join ($decomposed.ToCharArray() | Where-Object {
[Globalization.CharUnicodeInfo]::GetUnicodeCategory($_) -ne 'NonSpacingMark'
})
$normalized[$plain] += ,$c
}
$result = @()
foreach ($pair in $normalized.GetEnumerator()) {
if ($pair.Value -contains $pair.Key) {
$result += $pair.Key
} else {
$result += $pair.Value
}
}
return ($result | Sort-Object)
}
<###############################################################################
Récup. Users/Groups/Computers
###############################################################################>
## Main job
foreach ($ObjectType in $ObjectTypes) {
$cmd = "Get-AD$($ObjectType.Substring(0,1).ToUpper() + $ObjectType.Substring(1))"
if ($ObjectType -eq "organizationalUnit" -or $ObjectType -eq "contact") {
$cmd = "Get-ADObject"
}
$Properties = Get-Variable -Name "Properties_$ObjectType" -ValueOnly
$Proceed = Get-Variable -Name "GET_$ObjectType" -ValueOnly
if ($Proceed) {
foreach ($DOMAIN in $domains) {
## Build filter for Get-AD* commands
$exclude = $ObjectTypes | Where-Object { $_ -ne $ObjectType }
$filterParts = @()
$filterParts += "(ObjectClass -eq '$ObjectType')"
foreach ($ex in $exclude) {
$filterParts += "(ObjectClass -ne '$ex')"
}
$filter = $filterParts -join ' -and '
## Prepare CSV export
$csvPath = ".\data\${DOMAIN}-${ObjectType}s.csv"
if (Test-Path $csvPath) { Remove-Item $csvPath }
## Comptage du nombre d'objets
Write-Host "> Getting all $ObjectType objects from $DOMAIN..."
$ts = Measure-Command {
$objects = Get-ADObject -Filter $filter -Properties DistinguishedName -Server $DOMAIN
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
$nbobjects = $objects.count
Write-Host "> $DOMAIN contains $nbobjects ${ObjectType}s."
## Action si moins de 5k objets => récupération directe
if ($nbobjects -lt $LIMIT_SINGLEQUERY) {
Write-Host "> Gathering data in single query..."
$ts = Measure-Command {
& $cmd -Server $DOMAIN -Filter * -Properties $Properties |
ForEach-Object { $props = [ordered]@{}; foreach ($p in $Properties) { $v = $_.$p; if ($v -is [System.Collections.IEnumerable] -and -not ($v -is [string])) { $props[$p] = $v -join ";"; } else { $props[$p] = $v; } } [PSCustomObject]$props } |
Select-Object $Properties |
Export-Csv -Path $csvPath -Delimiter ';' -NoTypeInformation -Encoding UTF8 -Force
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
## Action si 5k objets ou plus => récupération par pages
} else {
Write-Host "> Gathering data in multiple queries by pages"
## a. Extraction de la liste des 1ers caractères
Write-Host "> Sorting objects by first character..."
$ts = Measure-Command {
$chars = [char[]]::new($objects.Count)
$total = $objects.Count
$i = 0
foreach ($item in $objects) {
$dn = $item.DistinguishedName
$s = $dn.Substring(3) # enlève "CN="
if ($s.Length -gt 0) {
$chars[$i] = [char]::ToLowerInvariant($s[0])
} else {
$chars[$i] = [char]0
}
$i++
}
$uchars = $chars | sort -unique
$uchars = Remove-AccentDuplicates -Chars $uchars
$nbchars = $uchars.count
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
Write-Host "> Dividing query in $nbchars chunks."
## b. Récupération de la liste des objets via commande powershell
$ts = Measure-Command {
Write-Host "> Gathering data..."
$objects = @()
$iteration = 0
foreach ($char in $uchars) {
$percent = [int]((++$iteration) * 100 / $nbchars)
Write-Progress -Activity "Building ${ObjectType}s list for $DOMAIN" -PercentComplete $percent -Status "$percent %"
$filter_char = $char -replace "'", "''"
& $cmd -Server $DOMAIN -Filter "Name -like '$filter_char*'" -Properties $Properties |
ForEach-Object { $props = [ordered]@{}; foreach ($p in $Properties) { $v = $_.$p; if ($v -is [System.Collections.IEnumerable] -and -not ($v -is [string])) { $props[$p] = $v -join ";"; } else { $props[$p] = $v; } } [PSCustomObject]$props } |
Select-Object $Properties |
Export-Csv -Path $csvPath -Delimiter ';' -NoTypeInformation -Encoding UTF8 -Append -Force
}
}
Write-Host "Duration: " -NoNewline ;"{0:hh\:mm\:ss}" -f $ts
} # if ($nbobjects -lt $LIMIT_SINGLEQUERY)
} # foreach ($DOMAIN in $domains)
} # if ($Proceed)
} # foreach ($ObjectType in $ObjectTypes)
#end#
--
TG - 10/2025