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 = @{}
    $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'
            $stateSet = $false

            if ($isStaged) {
                if ($existingStaged.ContainsKey($login)) {
                    $null = Invoke-FreeIPARequest -Method 'stageuser_mod' -Arguments $login -Options $options -Connection $connection -IgnoreError 'EmptyModlist'
                    $result.UpdatedUsers++
                }
                else {
                    $null = Invoke-FreeIPARequest -Method 'stageuser_add' -Arguments $login -Options $options -Connection $connection
                    $result.CreatedUsers++
                }
                $result.StagedUsers++
                $stateSet = $true
            }
            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.
                $result.UpdatedUsers++
                $result.PreservedUsers++
                $stateSet = $true
                Write-Verbose "Left $login preserved"
            }
            elseif ($existingActive.ContainsKey($login)) {
                $null = Invoke-FreeIPARequest -Method 'user_mod' -Arguments $login -Options $options -Connection $connection -IgnoreError 'EmptyModlist'
                $result.UpdatedUsers++
                Write-Verbose "Updated user $login"
            }
            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]
                }

                $temporary = $null
                if ($plainPassword -and $row.PasswordState -eq 'MustChange') { $options['userpassword'] = $plainPassword }
                elseif ($plainPassword -and $row.PasswordState -eq 'Current') {
                    $temporary = New-TestPassword -Length 24
                    $options['userpassword'] = $temporary
                }

                $null = Invoke-FreeIPARequest -Method 'user_add' -Arguments $login -Options $options -Connection $connection
                $result.CreatedUsers++
                Write-Verbose "Created user $login"

                if ($options.ContainsKey('userpassword')) {
                    if ($temporary) {
                        # Changed as the user, which is the one route to a password that is
                        # current rather than expired-on-arrival.
                        Set-FreeIPAPassword -Connection $connection -Username $login -OldPassword $temporary -NewPassword $plainPassword -Confirm:$false
                    }
                    $result.PasswordsSet++
                }

                if ($row.CertMapData) {
                    $issuer, $subject = $row.CertMapData -split '\|'
                    $null = Invoke-FreeIPARequest -Method 'user_add_certmapdata' -Arguments $login -Connection $connection `
                        -Options @{ issuer = $issuer; subject = $subject }
                }
            }

            if (-not $stateSet -and $isDisabled) {
                $null = Invoke-FreeIPARequest -Method 'user_disable' -Arguments $login -Connection $connection -IgnoreError 'AlreadyInactive'
                $result.DisabledUsers++
            }

            if (-not $isStaged -and -not $existingPreserved.ContainsKey($login) -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($login)
                }
            }

            $users.Add([PSCustomObject]@{
                    Username  = $login
                    Name      = $row.DisplayName
                    Lifecycle = $row.Lifecycle
                    Class     = $row.Class
                    Groups    = @(& $split $row.Groups)
                    Preserve  = ($isPreserved -and -not $existingPreserved.ContainsKey($login))
                })
        }
        catch {
            $message = "Failed to create user '$login': $($_.Exception.Message)"
            $result.Errors += $message
            Write-Error $message
        }
    }

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

    # 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.
    foreach ($user in ($users | Where-Object { $_.Preserve })) {
        if (-not $PSCmdlet.ShouldProcess($user.Username, 'Preserve FreeIPA user')) { continue }
        try {
            $null = Invoke-FreeIPARequest -Method 'user_del' -Arguments $user.Username -Options @{ preserve = $true } -Connection $connection
            $result.PreservedUsers++
            Write-Verbose "Preserved user $($user.Username)"
        }
        catch {
            $message = "Failed to preserve user '$($user.Username)': $($_.Exception.Message)"
            $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 }
}