Providers/AD/Public/New-ADTestUser.ps1
|
function New-ADTestUser { <# .EXTERNALHELP TestEnvironment-Help.xml .SYNOPSIS Creates Active Directory test user accounts from CSV data #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Colour-coded console progress is intentional; results are returned as objects.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '', Justification = 'Job blocks take param() and bind by -ArgumentList; Using: does not apply.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Passed into the job and read by the preference system, not by name.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '', Justification = 'AccountPassword arrives as a SecureString through -ArgumentList.')] [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType([PSCustomObject])] param( [switch]$IncludePhotos, [Parameter()] [ValidateRange(1, 50)] [int]$BatchSize = 15, [Parameter()] [ValidateRange(1, 8)] [int]$ThrottleLimit = 4, # Password every generated user is created with. # # A parameter rather than a literal buried in the job script block below, so a # caller who wants something other than the documented default can say so without # editing the module. The default is deliberately a known, weak, shared value: these # are lab accounts and being able to sign in as one is usually the point. Anything # random would have to be recorded somewhere to be usable, and this module already # has a vault for the accounts where that matters - the service accounts. [Parameter()] [ValidateNotNull()] [System.Security.SecureString]$AccountPassword = (ConvertTo-TestSecureString -PlainText 'Password123!'), [switch]$PassThru ) begin { $correlationId = [System.Guid]::NewGuid() Write-Verbose "Starting New-ADTestUser - CorrelationId: $correlationId" # Get data paths $dataPath = Get-ADTestDataPath $usersCSV = Join-Path $dataPath "ADUsers.csv" $imageFolder = Join-Path $dataPath "UserImages" # Verify prerequisites if (-not (Test-Path $usersCSV)) { throw "ADUsers.csv not found at: $usersCSV" } # Get domain information $domain = Get-ADTestDomain # Resolved once here: a job runs in a fresh runspace and cannot call back into the # module to ask for the marker. $seedMarker = Get-ADTestSeedMarker # Counters $script:UsersCreated = 0 $script:UsersSkipped = 0 $script:PhotosAdded = 0 $script:ManagersSet = 0 $script:Errors = @() $script:ProcessingJobs = [System.Collections.Generic.List[System.Management.Automation.Job]]::new() Write-Verbose "Batch processing configuration: BatchSize=$BatchSize, ThrottleLimit=$ThrottleLimit" } process { try { Write-TestMessage -Message "Creating Active Directory Test Users" -Type Header Write-TestMessage -Message "Loading user data from CSV..." -Type Info # Import user data $users = Import-Csv $usersCSV Write-Verbose "Loaded $($users.Count) users from CSV" # Sort users to create managers before their reports (simplified approach) $sortedUsers = $users | Sort-Object { if ([string]::IsNullOrWhiteSpace($_.Manager)) { 0 } else { 1 } } $totalUsers = $sortedUsers.Count Write-TestMessage -Message "Processing $totalUsers users in batches of $BatchSize..." -Type Info # Group users into batches $userBatches = @() for ($i = 0; $i -lt $totalUsers; $i += $BatchSize) { $batchEnd = [Math]::Min($i + $BatchSize - 1, $totalUsers - 1) $userBatches += ,@($sortedUsers[$i..$batchEnd]) } Write-Verbose "Created $($userBatches.Count) batches for processing" # Process batches with throttling $batchNumber = 0 $completedBatches = 0 foreach ($batch in $userBatches) { $batchNumber++ # Wait for available slot if at throttle limit while ($script:ProcessingJobs.Count -ge $ThrottleLimit) { Start-Sleep -Milliseconds 500 # Check for completed jobs $completedJobs = $script:ProcessingJobs | Where-Object { $_.State -eq 'Completed' } if ($completedJobs) { foreach ($job in $completedJobs) { $result = Receive-Job -Job $job Remove-Job -Job $job # Aggregate results $script:UsersCreated += $result.Created $script:UsersSkipped += $result.Skipped $script:PhotosAdded += $result.PhotosAdded $script:Errors += $result.Errors $completedBatches++ } # Remove completed jobs from tracking $remainingJobs = $script:ProcessingJobs | Where-Object { $_.State -ne 'Completed' } $script:ProcessingJobs.Clear() foreach ($job in $remainingJobs) { $script:ProcessingJobs.Add($job) } # Update progress # Reserve the last 20% for manager relationships $percentComplete = ($completedBatches / $userBatches.Count) * 80 Write-Progress -Activity "Creating User Batches" -Status ("Completed $completedBatches " + "of $($userBatches.Count) batches") -PercentComplete $percentComplete } } # Start new batch job $jobName = "UserBatch_$batchNumber" Write-Verbose "Starting batch $batchNumber with $($batch.Count) users" $job = Start-Job -Name $jobName -ScriptBlock { # $RootName is passed in because a job runs in a fresh runspace: module # scope does not travel, so $script:ADTestRootName is empty inside here and # every OU path built from it would be 'OU=Users,OU=,DC=...'. param($BatchRecord, $Domain, $IncludePhotos, $imageFolder, $WhatIfPreference, $VerbosePreference, $AccountPassword, $RootName, $SeedTag) # Import required modules in job Import-Module ActiveDirectory -Verbose:$false $batchResults = @{ Created = 0 Skipped = 0 PhotosAdded = 0 Errors = @() } foreach ($user in $BatchRecord) { try { # Debug: Check if required objects are available if (-not $Domain) { throw "Domain parameter is null" } if (-not $Domain.DomainDN) { throw "Domain.DomainDN is null" } if (-not $user) { throw "User object is null" } # Skip if user already exists $existingUser = Get-ADUser -Filter ("SamAccountName -eq " + "'$($user.SamAccountName)'") -ErrorAction SilentlyContinue if ($existingUser) { Write-Verbose "User $($user.SamAccountName) already exists, skipping" $batchResults.Skipped++ continue } # Determine OU path $ouPath = "OU=$($user.Department),OU=Users,OU=$RootName,$($Domain.DomainDN)" # Verify OU exists try { Get-ADOrganizationalUnit -Identity $ouPath -ErrorAction Stop | Out-Null } catch { Write-Verbose "OU not found: $ouPath. Using default Users container." $ouPath = "CN=Users,$($Domain.DomainDN)" } # Generate domain-dependent fields dynamically $dynamicEmail = if ([string]::IsNullOrWhiteSpace($user.mail)) { "$($user.SamAccountName)@$($Domain.DNSName)" } else { "$($user.mail)@$($Domain.DNSName)" } $dynamicUPN = if ([string]::IsNullOrWhiteSpace($user.UserPrincipalName)) { $dynamicEmail } else { "$($user.UserPrincipalName)@$($Domain.DNSName)" } # Prepare user parameters $userParams = @{ Name = $user.Name SamAccountName = $user.SamAccountName UserPrincipalName = $dynamicUPN GivenName = $user.GivenName Surname = $user.Surname DisplayName = $user.Name EmailAddress = $dynamicEmail Title = $user.Title Department = $user.Department OfficePhone = $user.OfficePhone MobilePhone = $user.MobilePhone Office = $user.Office StreetAddress = $user.StreetAddress City = $user.City State = $user.State PostalCode = $user.PostalCode Description = $user.Description EmployeeID = $user.EmployeeID # adminDescription carries the seed tag. The seeded people are the # one thing this module does not prefix, so this attribute is what # proves the account is ours once it is out of its container. OtherAttributes = @{ EmployeeType = $user.EmployeeType; adminDescription = $SeedTag } Path = $ouPath Enabled = if ([string]::IsNullOrWhiteSpace($user.Enabled)) { $true } else { [bool]::Parse($user.Enabled) } PasswordNeverExpires = $true CannotChangePassword = $false # Built by the caller and passed in. A SecureString survives # Start-Job -ArgumentList intact - verified, not assumed - # so the value never has to exist as plain text in here. AccountPassword = $AccountPassword } # Create user if (-not $WhatIfPreference) { Write-Verbose "Creating user: $($user.Name) in $ouPath" New-ADUser @userParams $batchResults.Created++ # Add photo if requested and available if ($IncludePhotos) { $photoPath = Join-Path $imageFolder "$($user.Name).jpg" if (Test-Path $photoPath) { try { $photo = [System.IO.File]::ReadAllBytes($photoPath) Set-ADUser -Identity $user.SamAccountName -Replace @{ thumbnailPhoto = $photo } Write-Verbose "Added photo for $($user.Name)" $batchResults.PhotosAdded++ } catch { $batchResults.Errors += ("Photo error for $($user.Name): " + "$($_.Exception.Message)") } } } } else { Write-Verbose "Would create user: $($user.Name) in $ouPath" $batchResults.Created++ } } catch { $batchResults.Errors += ("User creation error for $($user.Name): " + "$($_.Exception.Message)") } } return $batchResults } -ArgumentList $batch, $domain, $IncludePhotos, $imageFolder, $WhatIfPreference, $VerbosePreference, $AccountPassword, $script:ADTestRootName, $seedMarker.Tag $script:ProcessingJobs.Add($job) } # Wait for all remaining jobs to complete Write-Verbose "Waiting for all batch jobs to complete..." while ($script:ProcessingJobs.Count -gt 0) { Start-Sleep -Milliseconds 500 $completedJobs = $script:ProcessingJobs | Where-Object { $_.State -eq 'Completed' } if ($completedJobs) { foreach ($job in $completedJobs) { $result = Receive-Job -Job $job Remove-Job -Job $job # Aggregate results $script:UsersCreated += $result.Created $script:UsersSkipped += $result.Skipped $script:PhotosAdded += $result.PhotosAdded $script:Errors += $result.Errors $completedBatches++ } # Remove completed jobs from tracking $remainingJobs = $script:ProcessingJobs | Where-Object { $_.State -ne 'Completed' } $script:ProcessingJobs.Clear() foreach ($job in $remainingJobs) { $script:ProcessingJobs.Add($job) } # Update progress $percentComplete = ($completedBatches / $userBatches.Count) * 80 Write-Progress -Activity "Creating User Batches" -Status ("Completed $completedBatches of " + "$($userBatches.Count) batches") -PercentComplete $percentComplete } } # Second pass: Set manager relationships using batch processing if (-not $WhatIfPreference) { Write-TestMessage -Message "Setting manager relationships..." -Type Info Write-Progress -Activity "Creating Users" -Status ("Setting manager " + "relationships") -PercentComplete 85 # Filter users that need manager assignments $usersWithManagers = $sortedUsers | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Manager) -and $_.Manager -ne "CN=" } if ($usersWithManagers.Count -gt 0) { Write-Verbose "Processing manager assignments for $($usersWithManagers.Count) users" # Group manager assignments into batches $managerBatches = @() $managerBatchSize = [Math]::Min($BatchSize, 20) # Smaller batches for manager operations for ($i = 0; $i -lt $usersWithManagers.Count; $i += $managerBatchSize) { $batchEnd = [Math]::Min($i + $managerBatchSize - 1, $usersWithManagers.Count - 1) $managerBatches += ,@($usersWithManagers[$i..$batchEnd]) } Write-Verbose ("Created $($managerBatches.Count) manager assignment batches of size " + "$managerBatchSize") # Process manager batches with throttling $jobListType = [System.Collections.Generic.List[System.Management.Automation.Job]] $script:ManagerJobs = $jobListType::new() $managerBatchNumber = 0 $completedManagerBatches = 0 foreach ($batch in $managerBatches) { $managerBatchNumber++ # Wait for available slot if at throttle limit while ($script:ManagerJobs.Count -ge $ThrottleLimit) { Start-Sleep -Milliseconds 300 # Check for completed jobs $completedJobs = $script:ManagerJobs | Where-Object { $_.State -eq 'Completed' } if ($completedJobs) { foreach ($job in $completedJobs) { $result = Receive-Job -Job $job Remove-Job -Job $job # Aggregate results $script:ManagersSet += $result.ManagersSet $script:Errors += $result.Errors $completedManagerBatches++ } # Remove completed jobs from tracking $remainingJobs = $script:ManagerJobs | Where-Object { $_.State -ne 'Completed' } $script:ManagerJobs.Clear() foreach ($job in $remainingJobs) { $script:ManagerJobs.Add($job) } # Update progress $percentComplete = 85 + (($completedManagerBatches / $managerBatches.Count) * 10) Write-Progress -Activity "Creating Users" -Status ("Manager assignments: " + "$completedManagerBatches of $($managerBatches.Count) " + "batches") -PercentComplete $percentComplete } } # Start new manager batch job $jobName = "ManagerBatch_$managerBatchNumber" Write-Verbose "Starting manager batch $managerBatchNumber with $($batch.Count) assignments" $job = Start-Job -Name $jobName -ScriptBlock { param($BatchRecord, $VerbosePreference) # Import required modules in job Import-Module ActiveDirectory -Verbose:$false $batchResults = @{ ManagersSet = 0 Errors = @() } foreach ($user in $BatchRecord) { try { # Extract manager name from CN format $managerName = $user.Manager -replace "^CN=", "" # Escape single quotes in the name for AD filter $escapedManagerName = $managerName -replace "'", "''" $managerUser = Get-ADUser -Filter ("Name -eq " + "'$escapedManagerName'") -ErrorAction SilentlyContinue if ($managerUser) { $setADUserArgs1 = @{ Identity = $user.SamAccountName Manager = $managerUser.DistinguishedName } Set-ADUser @setADUserArgs1 Write-Verbose "Set manager for $($user.Name): $managerName" $batchResults.ManagersSet++ } else { $batchResults.Errors += "Manager not found for $($user.Name): $managerName" } } catch { $batchResults.Errors += ("Manager assignment error for $($user.Name): " + "$($_.Exception.Message)") } } return $batchResults } -ArgumentList $batch, $VerbosePreference $script:ManagerJobs.Add($job) } # Wait for all manager assignment jobs to complete Write-Verbose "Waiting for all manager assignment jobs to complete..." while ($script:ManagerJobs.Count -gt 0) { Start-Sleep -Milliseconds 300 $completedJobs = $script:ManagerJobs | Where-Object { $_.State -eq 'Completed' } if ($completedJobs) { foreach ($job in $completedJobs) { $result = Receive-Job -Job $job Remove-Job -Job $job # Aggregate results $script:ManagersSet += $result.ManagersSet $script:Errors += $result.Errors $completedManagerBatches++ } # Remove completed jobs from tracking $remainingJobs = $script:ManagerJobs | Where-Object { $_.State -ne 'Completed' } $script:ManagerJobs.Clear() foreach ($job in $remainingJobs) { $script:ManagerJobs.Add($job) } # Update progress $percentComplete = 85 + (($completedManagerBatches / $managerBatches.Count) * 10) Write-Progress -Activity "Creating Users" -Status ("Manager assignments: " + "$completedManagerBatches of $($managerBatches.Count) " + "batches") -PercentComplete $percentComplete } } Write-Verbose "Manager assignment processing completed" } else { Write-Verbose "No users require manager assignments" } } Write-Progress -Activity "Creating Users" -Status "Complete" -PercentComplete 100 -Completed # Create summary $results = @{ CorrelationId = $correlationId TotalUsers = $totalUsers CreatedUsers = $script:UsersCreated SkippedUsers = $script:UsersSkipped BatchesProcessed = $userBatches.Count BatchSize = $BatchSize ThrottleLimit = $ThrottleLimit PhotosAdded = if ($IncludePhotos) { $script:PhotosAdded } else { 0 } ManagersSet = $script:ManagersSet Errors = $script:Errors } # Display summary Write-TestMessage -Message "User Creation Summary (Batch Mode)" -Type Success Write-Host " User Creation Batches: $($results.BatchesProcessed)" -ForegroundColor Cyan Write-Host " Batch Size: $($results.BatchSize)" -ForegroundColor Cyan Write-Host " Users Created: $($results.CreatedUsers)" -ForegroundColor Green Write-Host " Users Skipped: $($results.SkippedUsers)" -ForegroundColor Yellow if ($IncludePhotos) { Write-Host " Photos Added: $($results.PhotosAdded)" -ForegroundColor Green } Write-Host " Managers Set: $($results.ManagersSet)" -ForegroundColor Green if ($results.Errors.Count -gt 0) { Write-Host " Errors: $($results.Errors.Count)" -ForegroundColor Red $results.Errors | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } } if ($PassThru) { return [PSCustomObject]$results } } catch { # Cleanup any remaining jobs on error if ($script:ProcessingJobs.Count -gt 0) { Write-Verbose ("Cleaning up $($script:ProcessingJobs.Count) user creation background jobs due " + "to error") foreach ($job in $script:ProcessingJobs) { Stop-Job -Job $job -ErrorAction SilentlyContinue Remove-Job -Job $job -ErrorAction SilentlyContinue } $script:ProcessingJobs.Clear() } # Cleanup manager assignment jobs if they exist. # # Guarded on the variable itself. This used to read # if (Get-Variable -Name 'script:ManagerJobs' -ErrorAction SilentlyContinue # -and $script:ManagerJobs.Count -gt 0) # where -and binds as a PARAMETER to Get-Variable rather than as an operator. # Get-Variable has no -and, so this line threw "A parameter cannot be found that # matches parameter name 'and'" every time the handler ran - replacing the real # failure with a confusing one and leaking the jobs it exists to stop. The # scope-qualified name would not have resolved through -Name either. if ($script:ManagerJobs -and $script:ManagerJobs.Count -gt 0) { Write-Verbose ("Cleaning up $($script:ManagerJobs.Count) manager assignment background jobs due " + "to error") foreach ($job in $script:ManagerJobs) { Stop-Job -Job $job -ErrorAction SilentlyContinue Remove-Job -Job $job -ErrorAction SilentlyContinue } $script:ManagerJobs.Clear() } Write-Error "Failed to create users: $($_.Exception.Message)" -ErrorAction Stop } } end { Write-Verbose "Completed New-ADTestUser - CorrelationId: $correlationId" } } |