Providers/AD/Public/New-ADTestServiceAccount.ps1

function New-ADTestServiceAccount {
    <#
    .EXTERNALHELP TestEnvironment-Help.xml
    .SYNOPSIS
        Creates Active Directory test service accounts from CSV data
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Colour-coded console progress is intentional; results are returned as objects.')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType([System.Collections.Hashtable])]
    param(
        [Parameter()]
        [switch]$PassThru,

        [Parameter()]
        [switch]$UseSecretStore,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$VaultName = "ADTestEnvironment",

        [Parameter()]
        [System.Security.SecureString]$VaultPassword,

        [Parameter()]
        [switch]$GlobalVault
    )

    begin {
        $correlationId = [System.Guid]::NewGuid()
        Write-Verbose "Starting New-ADTestServiceAccount - CorrelationId: $correlationId"

        # Get data paths
        $dataPath = Get-ADTestDataPath
        $serviceAccountsCSV = Join-Path $dataPath "ADServiceAccounts.csv"

        # Verify prerequisites
        if (-not (Test-Path $serviceAccountsCSV)) {
            throw "ADServiceAccounts.csv not found at: $dataPath"
        }

        # Get domain information
        $domain = Get-ADTestDomain

        # Counters
        $script:ServiceAccountsCreated = 0
        $script:ServiceAccountsSkipped = 0
        $script:Errors = @()
        $script:PasswordExports = @()
    }

    process {
        try {
            Write-TestMessage -Message "Creating Active Directory Test Service Accounts" -Type Header
            Write-TestMessage -Message "Loading service account data from CSV..." -Type Info

            # Import service account data
            $serviceAccounts = Import-Csv $serviceAccountsCSV
            Write-Verbose "Loaded $($serviceAccounts.Count) service accounts from CSV"

            $totalAccounts = $serviceAccounts.Count
            $currentAccount = 0

            Write-TestMessage -Message "Processing $totalAccounts service accounts..." -Type Info

            foreach ($serviceAccount in $serviceAccounts) {
                $currentAccount++
                $percentComplete = ($currentAccount / $totalAccounts) * 100

                Write-Progress -Activity "Creating Service Accounts" -Status ("Processing " +
                    "$($serviceAccount.SamAccountName)") -PercentComplete $percentComplete

                try {
                    # Skip if service account already exists
                    $existingAccount = Get-ADUser -Filter ("SamAccountName -eq " +
                        "'$($serviceAccount.SamAccountName)'") -ErrorAction SilentlyContinue
                    if ($existingAccount) {
                        Write-Verbose "Service account $($serviceAccount.SamAccountName) already exists, skipping"
                        $script:ServiceAccountsSkipped++
                        continue
                    }

                    # Generate cryptographically secure password. The plain form is kept
                    # only long enough to record it in the export below, which is the whole
                    # point of the export - an operator has to be able to read these back.
                    $password = New-TestPassword -Length 16
                    $securePassword = ConvertTo-TestSecureString -PlainText $password

                    # Store password for export
                    $newPasswordExportEntryArgs1 = @{
                        ServiceAccountName = $serviceAccount.SamAccountName
                        Password           = $password
                        Description        = $serviceAccount.Description
                    }
                    New-ADTestPasswordExportEntry @newPasswordExportEntryArgs1

                    # Get manager if specified
                    $manager = $null
                    if (-not [string]::IsNullOrWhiteSpace($serviceAccount.Manager)) {
                        # Handle both DN format (CN=Name) and plain name format
                        $managerName = $serviceAccount.Manager
                        if ($managerName.StartsWith('CN=')) {
                            $managerName = $managerName.Substring(3)  # Remove 'CN=' prefix
                        }

                        $manager = Get-ADUser -Filter "Name -eq '$managerName'" -ErrorAction SilentlyContinue
                        if (-not $manager) {
                            Write-Warning ("Manager '$managerName' not found for service account " +
                                "$($serviceAccount.SamAccountName)")
                        }
                    }

                    # Determine OU path
                    $ouPath = "OU=ServiceAccounts,OU=$($script:ADTestRootName),$($domain.DomainDN)"

                    # Verify OU exists
                    try {
                        Get-ADOrganizationalUnit -Identity $ouPath -ErrorAction Stop | Out-Null
                    }
                    catch {
                        Write-Warning ("OU not found: $ouPath. Skipping service account creation for " +
                            "$($serviceAccount.SamAccountName). Please ensure OU structure is created first.")
                        $script:ServiceAccountsSkipped++
                        continue
                    }

                    # Generate dynamic email address
                    $dynamicEmail = if ([string]::IsNullOrWhiteSpace($serviceAccount.mail)) {
                        "$($serviceAccount.SamAccountName)@$($domain.DNSName)"
                    } else {
                        "$($serviceAccount.mail)@$($domain.DNSName)"
                    }

                    # A service account is not a person, so it takes the prefix even though the
                    # seeded human users do not. On the name and display name only: it is a user
                    # object, and a user's sAMAccountName is capped at 20 characters, which
                    # 'ZZ-TEST-' plus a service account name overruns.
                    $seed = Get-ADTestSeedMarker
                    $prefixedName = '{0}{1}' -f $seed.Prefix, $serviceAccount.Name

                    $accountParams = @{
                        Name = $prefixedName
                        SamAccountName = $serviceAccount.SamAccountName
                        UserPrincipalName = "$($serviceAccount.SamAccountName)@$($domain.DNSName)"
                        EmailAddress = $dynamicEmail
                        GivenName = $serviceAccount.GivenName
                        Surname = $serviceAccount.Surname
                        DisplayName = $prefixedName

                        # Description stays exactly what the CSV says; the seed tag goes in
                        # adminDescription, which nothing else in the directory writes.
                        Description = $serviceAccount.Description
                        OtherAttributes = @{ adminDescription = $seed.Tag }
                        Department = $serviceAccount.Department
                        Title = $serviceAccount.Title
                        Office = $serviceAccount.Office
                        StreetAddress = $serviceAccount.StreetAddress
                        City = $serviceAccount.City
                        State = $serviceAccount.State
                        PostalCode = $serviceAccount.PostalCode
                        AccountPassword = $securePassword
                        Enabled = $true
                        CannotChangePassword = $true
                        PasswordNeverExpires = $true
                        Path = $ouPath
                    }

                    # Add manager if found (only add parameter if manager exists)
                    if ($manager) {
                        $accountParams.Manager = $manager.DistinguishedName
                        Write-Verbose "Setting manager for $($serviceAccount.SamAccountName): $($manager.Name)"
                    } else {
                        Write-Verbose "No manager specified or found for $($serviceAccount.SamAccountName)"
                    }

                    # Create service account
                    if ($PSCmdlet.ShouldProcess($serviceAccount.SamAccountName, "Create AD Service Account")) {
                        Write-Verbose "Creating service account: $($serviceAccount.SamAccountName)"
                        $newAccount = New-ADUser @accountParams -PassThru

                        # Set additional properties that require the account to exist
                        Set-ADUser -Identity $newAccount.DistinguishedName -SmartcardLogonRequired $false

                        # These accounts are NOT denied interactive logon. The previous code
                        # built an array of SeDeny*LogonRight names here and then discarded
                        # it, so the comment claimed a hardening step that never ran. Those
                        # are LSA account rights, not AD attributes: they are granted per
                        # machine, so a domain-wide equivalent means a GPO ("Deny log on
                        # locally" and friends) with these accounts as members, not anything
                        # New-ADUser or Set-ADUser can express. Left as a documented gap
                        # rather than a line of code that looks like it does the job.
                        Write-Verbose ("Service account $($serviceAccount.SamAccountName) " +
                            'created; deny-logon rights are not applied - see the GPO note')

                        Write-Verbose "Service account $($serviceAccount.SamAccountName) created successfully"
                        $script:ServiceAccountsCreated++
                    }
                    else {
                        Write-Host ("Would create service account: " +
                            "$($serviceAccount.SamAccountName)") -ForegroundColor Green
                    }
                }
                catch {
                    Write-Error ("Failed to create service account $($serviceAccount.SamAccountName): " +
                        "$($_.Exception.Message)")
                    $script:Errors += ("Service account creation error for $($serviceAccount.SamAccountName): " +
                        "$($_.Exception.Message)")
                }
            }

            Write-Progress -Activity "Creating Service Accounts" -Status "Complete" -PercentComplete 100 -Completed

            # Export passwords to file or SecretStore if accounts were created
            $passwordFile = $null
            $secretStoreResult = $null
            if ($script:PasswordExports.Count -gt 0 -and -not $WhatIfPreference) {
                Write-Verbose "Generated passwords for $($script:PasswordExports.Count) service accounts"

                # Handle SecretStore orchestration if requested
                if ($UseSecretStore) {
                    try {
                        $orchestrationParams = @{
                            PasswordData = $script:PasswordExports
                            VaultName = $VaultName
                            GlobalVault = $GlobalVault
                            CorrelationId = $correlationId
                        }

                        if ($VaultPassword) {
                            $orchestrationParams.VaultPassword = $VaultPassword
                        }

                        $secretStoreResult = Invoke-ADTestSecretStoreOrchestration @orchestrationParams

                        if ($secretStoreResult.Errors.Count -gt 0) {
                            Write-Warning ("SecretStore orchestration completed with errors: " +
                                "$($secretStoreResult.Errors -join '; ')")
                            # Fall back to file export
                            $exportPasswordDocumentationArgs2 = @{
                                PasswordData = $script:PasswordExports
                                FilePrefix   = "ServiceAccountPW"
                            }
                            $passwordFile = Export-ADTestPasswordDocumentation @exportPasswordDocumentationArgs2
                            Write-Warning "Passwords exported to file as fallback: $passwordFile"
                        }
                        else {
                            Write-Verbose ("Successfully stored $($secretStoreResult.PasswordsStored) passwords " +
                                "in SecretStore vault: $VaultName")
                        }
                    }
                    catch {
                        Write-Warning "SecretStore orchestration failed: $($_.Exception.Message)"
                        # Fall back to file export
                        $exportPasswordDocumentationArgs3 = @{
                            PasswordData = $script:PasswordExports
                            FilePrefix   = "ServiceAccountPW"
                        }
                        $passwordFile = Export-ADTestPasswordDocumentation @exportPasswordDocumentationArgs3
                        Write-Warning "Passwords exported to file as fallback: $passwordFile"
                    }
                }
                else {
                    # Export to file when not using SecretStore
                    $exportPasswordDocumentationArgs4 = @{
                        PasswordData = $script:PasswordExports
                        FilePrefix   = "ServiceAccountPW"
                    }
                    $passwordFile = Export-ADTestPasswordDocumentation @exportPasswordDocumentationArgs4
                    Write-Verbose "Service account passwords exported to: $passwordFile"
                }
            }

            # Create summary
            $results = @{
                CorrelationId = $correlationId
                TotalAccounts = $totalAccounts
                CreatedAccounts = $script:ServiceAccountsCreated
                SkippedAccounts = $script:ServiceAccountsSkipped
                PasswordData = $script:PasswordExports
                PasswordFile = if ($passwordFile) { $passwordFile } else { $null }
                SecretStoreResult = if ($secretStoreResult) { $secretStoreResult } else { $null }
                UseSecretStore = $UseSecretStore
                VaultName = if ($UseSecretStore) { $VaultName } else { $null }
                Errors = $script:Errors
            }

            # Display summary or return results
            if ($PassThru) {
                return [PSCustomObject]$results
            } else {
                Write-TestMessage -Message "Service Account Creation Summary" -Type Success
                Write-Host " Accounts Created: $($results.CreatedAccounts)" -ForegroundColor Green
                Write-Host " Accounts Skipped: $($results.SkippedAccounts)" -ForegroundColor Yellow

                if ($results.PasswordData.Count -gt 0) {
                    Write-Host " Passwords Generated: $($results.PasswordData.Count)" -ForegroundColor Cyan
                    Write-Host (" INFO: Password data available in results object for external " +
                        "handling") -ForegroundColor Green
                }

                if ($results.Errors.Count -gt 0) {
                    Write-Host " Errors: $($results.Errors.Count)" -ForegroundColor Red
                    $results.Errors | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
                }
            }

        } catch {
            Write-Error "Failed to create service accounts: $($_.Exception.Message)" -ErrorAction Stop
        }
    }

    end {
        Write-Verbose "Completed New-ADTestServiceAccount - CorrelationId: $correlationId"
    }
}