Providers/FreeIPA/Public/New-FreeIPAUser.ps1

function New-FreeIPAUser {
    <#
    .EXTERNALHELP TestEnvironment-Help.xml
    .SYNOPSIS
        Creates the seeded FreeIPA users from Data\FreeIPAUsers.csv, in their groups and lifecycle states
    #>


    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [string[]]$UserName,

        [Parameter()]
        [ValidateSet('Core', 'Bulk')]
        [string[]]$Tier,

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

        [Parameter()]
        [switch]$SkipGroups,

        [Parameter()]
        [switch]$ShowProgress,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-FreeIPAConnection
    $marker = Get-FreeIPASeedMarker -Connection $connection

    $csvPath = Join-Path -Path (Get-FreeIPADataPath) -ChildPath 'FreeIPAUsers.csv'
    $allRows = @(Import-Csv -Path $csvPath -Encoding UTF8)
    $rows = $allRows

    if ($Tier) { $rows = @($rows | Where-Object { $Tier -contains $_.Tier }) }
    if ($UserName) {
        $rows = @($allRows | Where-Object { $UserName -contains $_.Username })
        $unknown = @($UserName | Where-Object { $rows.Username -notcontains $_ })
        if ($unknown) { throw "No definition in $csvPath for: $($unknown -join ', ')" }
    }

    # Managers first. A manager has to exist before the person who reports to them, so the
    # rows are ordered by the length of the chain above them, from the full CSV so a partial
    # selection keeps the same order.
    $managerOf = @{}
    foreach ($row in $allRows) { $managerOf[$row.Username] = $row.Manager }
    $depthOf = {
        param($key)
        $depth = 0
        $current = $managerOf[$key]
        while ($current -and $depth -lt 50) { $depth++; $current = $managerOf[$current] }
        $depth
    }
    $rows = @($rows | Sort-Object -Property @{ Expression = { & $depthOf $_.Username } }, Username)

    $result = [PSCustomObject]@{
        TotalUsers         = $rows.Count
        CreatedUsers       = 0
        UpdatedUsers       = 0
        StagedUsers        = 0
        PreservedUsers     = 0
        DisabledUsers      = 0
        PasswordsSet       = 0
        MembershipsApplied = 0
        ManagersApplied    = 0
        Users              = @()
        Errors             = @()
    }

    $split = { param($value) @([string]$value -split ';' | Where-Object { $_ }) }

    $existingActive = @{}
    foreach ($user in (Get-FreeIPASeededObject -Type Users -Connection $connection)) { $existingActive[[string](@($user.uid)[0])] = $user }
    $existingStaged = @{}
    foreach ($user in (Get-FreeIPASeededObject -Type StagedUsers -Connection $connection)) { $existingStaged[[string](@($user.uid)[0])] = $user }
    $existingPreserved = @{}
    foreach ($user in (Get-FreeIPASeededObject -Type PreservedUsers -Connection $connection)) { $existingPreserved[[string](@($user.uid)[0])] = $user }

    $plainPassword = $null
    if ($AccountPassword) { $plainPassword = ConvertFrom-TestSecureString -SecureString $AccountPassword }

    # The GID a user with no private group takes: that of a seeded POSIX group, read once.
    $gidByGroupKey = @{}

    $users = [System.Collections.Generic.List[object]]::new()
    $membersOf = @{}
    # Decided row by row, sent in batches. FreeIPA's JSON-RPC batch method carries many
    # commands in one round trip, and the round trip was where the seed's time went: 357
    # users one call each was ten minutes of waiting on the wire. Each row is still decided,
    # confirmed and reported one at a time; only the sending is shared.
    $plans = [System.Collections.Generic.List[object]]::new()
    $index = 0

    foreach ($row in $rows) {
        $login = $row.Username
        $index++
        Write-TestProgress -Activity 'Seeding users' -Status "$index of $($rows.Count): $login" `
            -PercentComplete ([int](100 * $index / [Math]::Max(1, $rows.Count))) -ShowProgress:$ShowProgress

        if (-not $PSCmdlet.ShouldProcess("$($row.DisplayName) ($login, $($row.Lifecycle))", 'Create FreeIPA user')) { continue }

        try {
            $options = @{
                givenname   = $row.GivenName
                sn          = $row.Surname
                cn          = $row.DisplayName
                displayname = $row.DisplayName
                userclass   = [object[]]@(@($marker.Tag) + @(& $split $row.Class))
                loginshell  = $row.LoginShell
            }
            if ($row.Title) { $options['title'] = $row.Title }
            if ($row.OrgUnit) { $options['ou'] = $row.OrgUnit }
            if ($row.Manager) { $options['manager'] = $row.Manager }
            if ($row.EmployeeNumber) { $options['employeenumber'] = $row.EmployeeNumber }
            if ($row.EmployeeType) { $options['employeetype'] = $row.EmployeeType }
            if ($row.HomeDirectory) { $options['homedirectory'] = $row.HomeDirectory }
            if ($row.Phone) { $options['telephonenumber'] = [object[]]@($row.Phone) }
            if ($row.Mobile) { $options['mobile'] = [object[]]@($row.Mobile) }
            if ($row.Street) { $options['street'] = $row.Street }
            if ($row.City) { $options['l'] = $row.City }
            if ($row.State) { $options['st'] = $row.State }
            if ($row.PostalCode) { $options['postalcode'] = $row.PostalCode }
            if ($row.PreferredLanguage) { $options['preferredlanguage'] = $row.PreferredLanguage }
            if ($row.UserAuthType) { $options['ipauserauthtype'] = [object[]]@(& $split $row.UserAuthType) }
            # Where a radius or idp authentication type authenticates: a seeded proxy or
            # provider, by its realm name, and the login the user has there.
            if ($row.RadiusProxy) { $options['ipatokenradiusconfiglink'] = Resolve-FreeIPASeedName -Key $row.RadiusProxy -Marker $marker -Connection $connection }
            if ($row.RadiusUsername) { $options['ipatokenradiususername'] = $row.RadiusUsername }
            if ($row.IdentityProvider) { $options['ipaidpconfiglink'] = Resolve-FreeIPASeedName -Key $row.IdentityProvider -Marker $marker -Connection $connection }
            if ($row.IdpUserId) { $options['ipaidpsub'] = $row.IdpUserId }
            if ($row.SshPublicKeys) { $options['ipasshpubkey'] = [object[]]@(& $split $row.SshPublicKeys) }
            if ($row.PrincipalExpiresInDays -match '^-?\d+$') {
                $options['krbprincipalexpiration'] = ConvertTo-FreeIPADateTime -Value ([DateTimeOffset]::UtcNow.AddDays([int]$row.PrincipalExpiresInDays))
            }

            $isStaged = $row.Lifecycle -eq 'Staged'
            $isPreserved = $row.Lifecycle -eq 'Preserved'
            $isDisabled = $row.Lifecycle -eq 'Disabled'
            # The plan says which command this row needs; the commands go below, in batches.
            $plan = [PSCustomObject]@{
                Row = $row; Login = $login; Method = $null; Options = $options; IgnoreError = @()
                Outcome = $null; Temporary = $null; IsStaged = $isStaged; IsDisabled = $isDisabled
                LeftPreserved = $false; Preserve = ($isPreserved -and -not $existingPreserved.ContainsKey($login))
            }
            if ($isStaged) {
                if ($existingStaged.ContainsKey($login)) { $plan.Method = 'stageuser_mod'; $plan.IgnoreError = @('EmptyModlist'); $plan.Outcome = 'Updated' }
                else { $plan.Method = 'stageuser_add'; $plan.Outcome = 'Created' }
            }
            elseif ($existingPreserved.ContainsKey($login)) {
                # Already preserved. Every membership is gone and the entry is the audit trail;
                # re-adding would mean un-preserving, which is not what a re-run means.
                $plan.LeftPreserved = $true
                $plan.Outcome = 'Updated'
            }
            elseif ($existingActive.ContainsKey($login)) {
                $plan.Method = 'user_mod'; $plan.IgnoreError = @('EmptyModlist'); $plan.Outcome = 'Updated'
            }
            else {
                if ($row.NoPrivateGroup -eq 'TRUE') {
                    $groupKey = $row.PrimaryGroup
                    if (-not $gidByGroupKey.ContainsKey($groupKey)) {
                        $groupName = Resolve-FreeIPASeedName -Key $groupKey -Marker $marker -Connection $connection
                        $shown = Invoke-FreeIPARequest -Method 'group_show' -Arguments $groupName -Connection $connection
                        $gidByGroupKey[$groupKey] = [int](@($shown.result.gidnumber)[0])
                    }
                    $options['noprivate'] = $true
                    $options['gidnumber'] = $gidByGroupKey[$groupKey]
                }
                if ($plainPassword -and $row.PasswordState -eq 'MustChange') { $options['userpassword'] = $plainPassword }
                elseif ($plainPassword -and $row.PasswordState -eq 'Current') {
                    $plan.Temporary = New-TestPassword -Length 24
                    $options['userpassword'] = $plan.Temporary
                }
                $plan.Method = 'user_add'; $plan.Outcome = 'Created'
            }
            $plans.Add($plan)
        }
        catch {
            $message = "Failed to create user '$login': $($_.Exception.Message)"
            $result.Errors += $message
            Write-Error $message
        }
    }

    Write-TestProgress -Activity 'Seeding users' -Completed -ShowProgress:$ShowProgress

    # The primary command for every row, fifty to a request. A row the realm refused fails alone
    # and takes no further part; its message is recorded as it was when the calls were one each.
    $failed = @{}
    $primary = @($plans | Where-Object { $_.Method })
    if ($primary.Count -gt 0) {
        $commands = @($primary | ForEach-Object { @{ Method = $_.Method; Arguments = @($_.Login); Options = $_.Options; IgnoreError = $_.IgnoreError; Tag = $_.Login } })
        foreach ($answer in @(Invoke-FreeIPABatch -Command $commands -Connection $connection)) {
            if ($answer.Success) { continue }
            $failed[[string]$answer.Command.Tag] = $true
            $message = "Failed to create user '$($answer.Command.Tag)': $($answer.ErrorMessage)"
            $result.Errors += $message
            Write-Error $message
        }
    }
    $done = @($plans | Where-Object { -not $failed.ContainsKey($_.Login) })
    foreach ($plan in $done) {
        if ($plan.Outcome -eq 'Created') { $result.CreatedUsers++; Write-Verbose "Created user $($plan.Login)" }
        else { $result.UpdatedUsers++; Write-Verbose "Updated user $($plan.Login)" }
        if ($plan.IsStaged) { $result.StagedUsers++ }
        if ($plan.LeftPreserved) { $result.PreservedUsers++; Write-Verbose "Left $($plan.Login) preserved" }
    }

    # Passwords. MustChange went with the add and is already what an admin-set password is.
    # Current is changed as the user, one call each, because the change endpoint is a form the
    # user posts rather than a command an administrator can batch.
    foreach ($plan in @($done | Where-Object { $_.Method -eq 'user_add' -and $_.Options.ContainsKey('userpassword') })) {
        try {
            if ($plan.Temporary) {
                Set-FreeIPAPassword -Connection $connection -Username $plan.Login -OldPassword $plan.Temporary -NewPassword $plainPassword -Confirm:$false
            }
            $result.PasswordsSet++
        }
        catch {
            $message = "Failed to set the password of '$($plan.Login)': $($_.Exception.Message)"
            $result.Errors += $message
            Write-Error $message
        }
    }

    # Certificate mapping data and the disabled state, batched the same way.
    $follow = [System.Collections.Generic.List[object]]::new()
    foreach ($plan in $done) {
        if ($plan.Method -eq 'user_add' -and $plan.Row.CertMapData) {
            $issuer, $subject = $plan.Row.CertMapData -split '\|'
            $follow.Add(@{ Method = 'user_add_certmapdata'; Arguments = @($plan.Login); Options = @{ issuer = $issuer; subject = $subject }; Tag = "add certificate mapping data to '$($plan.Login)'"; Disables = $false })
        }
        if ($plan.IsDisabled -and -not $plan.IsStaged -and -not $plan.LeftPreserved) {
            $follow.Add(@{ Method = 'user_disable'; Arguments = @($plan.Login); Options = @{}; IgnoreError = @('AlreadyInactive'); Tag = "disable '$($plan.Login)'"; Disables = $true })
        }
    }
    if ($follow.Count -gt 0) {
        foreach ($answer in @(Invoke-FreeIPABatch -Command $follow.ToArray() -Connection $connection)) {
            if ($answer.Success) {
                if ($answer.Command.Disables) { $result.DisabledUsers++ }
                continue
            }
            $message = "Failed to $($answer.Command.Tag): $($answer.ErrorMessage)"
            $result.Errors += $message
            Write-Error $message
        }
    }

    foreach ($plan in $done) {
        $row = $plan.Row
        if (-not $plan.IsStaged -and -not $plan.LeftPreserved -and -not $SkipGroups) {
            foreach ($groupKey in (& $split $row.Groups)) {
                if (-not $membersOf.ContainsKey($groupKey)) { $membersOf[$groupKey] = [System.Collections.Generic.List[string]]::new() }
                $membersOf[$groupKey].Add($plan.Login)
            }
        }
        $users.Add([PSCustomObject]@{
                Username  = $plan.Login
                Name      = $row.DisplayName
                Lifecycle = $row.Lifecycle
                Class     = $row.Class
                Groups    = @(& $split $row.Groups)
                Preserve  = $plan.Preserve
            })
    }
    # Membership, one call per group. The users to be preserved are members here, so that
    # preserving strips something, which is the state the row describes.
    foreach ($groupKey in ($membersOf.Keys | Sort-Object)) {
        $groupName = Resolve-FreeIPASeedName -Key $groupKey -Marker $marker -Connection $connection
        $members = @($membersOf[$groupKey])
        if (-not $PSCmdlet.ShouldProcess($groupName, "Add $($members.Count) member user(s)")) { continue }
        for ($start = 0; $start -lt $members.Count; $start += 100) {
            $chunk = @($members[$start..([Math]::Min($start + 99, $members.Count - 1))])
            try {
                $outcome = Invoke-FreeIPARequest -Method 'group_add_member' -Arguments $groupName -Connection $connection `
                    -Options @{ user = [object[]]$chunk }
                $result.MembershipsApplied += [int]$outcome.completed
                foreach ($failure in @(Get-FreeIPAMemberFailure -Outcome $outcome)) {
                    if ($failure -like '*already a member*') { continue }
                    $message = "Could not add to group '$groupName': $failure"
                    $result.Errors += $message
                    Write-Error $message
                }
            }
            catch {
                $message = "Failed to add members to group '$groupName': $($_.Exception.Message)"
                $result.Errors += $message
                Write-Error $message
            }
        }
    }

    # The member managers the groups file names by login. New-FreeIPAGroup runs before any
    # user exists, so the users it could not name as managers are applied here, for the
    # users this run processed; a manager outside the selection is left for a fuller run.
    if (-not $SkipGroups) {
        $processed = @($users | ForEach-Object { $_.Username })
        $groupRows = @(Import-Csv -Path (Join-Path -Path (Get-FreeIPADataPath) -ChildPath 'FreeIPAGroups.csv') -Encoding UTF8)
        foreach ($groupRow in ($groupRows | Where-Object { $_.ManagerUsers })) {
            $managers = @(& $split $groupRow.ManagerUsers | Where-Object { $processed -contains $_ })
            if ($managers.Count -eq 0) { continue }
            $groupName = Resolve-FreeIPASeedName -Key $groupRow.Name -Marker $marker -Connection $connection
            if (-not $PSCmdlet.ShouldProcess($groupName, "Add $($managers.Count) member manager(s)")) { continue }
            $added = Add-FreeIPAMember -Method 'group_add_member_manager' -Name $groupName -Members @{ user = $managers } -Connection $connection
            $result.ManagersApplied += $added.Completed
            foreach ($problem in $added.Errors) {
                $result.Errors += "Group '$groupName': $problem"
                Write-Error "Group '$groupName': $problem"
            }
        }
    }

    # Preserving last, after the memberships it strips, in one batch.
    $preserving = @($users | Where-Object { $_.Preserve -and $PSCmdlet.ShouldProcess($_.Username, 'Preserve FreeIPA user') })
    if ($preserving.Count -gt 0) {
        $commands = @($preserving | ForEach-Object { @{ Method = 'user_del'; Arguments = @($_.Username); Options = @{ preserve = $true }; Tag = $_.Username } })
        foreach ($answer in @(Invoke-FreeIPABatch -Command $commands -Connection $connection)) {
            if ($answer.Success) { $result.PreservedUsers++; Write-Verbose "Preserved user $($answer.Command.Tag)"; continue }
            $message = "Failed to preserve user '$($answer.Command.Tag)': $($answer.ErrorMessage)"
            $result.Errors += $message
            Write-Error $message
        }
    }
    $result.Users = @($users | Select-Object -Property Username, Name, Lifecycle, Class, Groups)

    Write-Verbose ("Users: $($result.CreatedUsers) created, $($result.UpdatedUsers) updated, $($result.StagedUsers) staged, " +
        "$($result.PreservedUsers) preserved, $($result.DisabledUsers) disabled, $($result.PasswordsSet) passwords set, " +
        "$($result.MembershipsApplied) memberships, $($result.Errors.Count) problems")

    if ($PassThru) { return $result }
}