Providers/FreeIPA/Public/New-FreeIPAServiceApp.ps1

function New-FreeIPAServiceApp {
    <#
    .SYNOPSIS
        Creates the automation service account the module connects as from then on

    .DESCRIPTION
        FreeIPA's equivalent of a service app is a user with no shell whose only purpose is the
        API. This creates one with a random password, adds it to the admins group so it can
        create and delete everything the seed touches, and then deals with the two things
        FreeIPA does to a fresh password: the random one the server handed back is expired the
        moment it was set, so it is changed once as the user to a second random one, which is
        current; and the realm's policy would expire that on its own schedule, so the
        account's password expiry is set far into the future. The account is then proven by
        logging in with it, and the credential record that Connect-FreeIPAEnvironment
        -ServiceAccount reads is written, with the pinned certificate authority inside it.

        The password is written where the record says: into a SecretStore vault under
        -UseSecretStore, which is proven usable before the account exists so a created account
        is never left without a saved password, or DPAPI-protected in the record itself.

        The account is a seeded user in every respect but one: teardown keeps it unless told
        otherwise, because it is the credential doing the tearing down.

    .PARAMETER CredentialPath
        Where to write the credential record, when not the default location.

    .PARAMETER UseSecretStore
        Keep the password in a SecretStore vault rather than in the record.

    .PARAMETER VaultName
        The vault to use with -UseSecretStore.

    .PARAMETER VaultPassword
        The vault's password, when it is not the module default.

    .PARAMETER Force
        Replace an existing service account and mint a new password.

    .PARAMETER PassThru
        Returns the result object.

    .OUTPUTS
        PSCustomObject with BaseUrl, Username, AdminGroup, CredentialPath, Protection,
        VaultName, HandoverVerified and Warnings.

    .EXAMPLE
        PS> New-FreeIPAServiceApp

        DESCRIPTION: Creates the service account and writes the record
        OUTPUT: A banner naming the command to connect with from now on
        USE CASE: Once per realm, after connecting with an administrator's credential

    .EXAMPLE
        PS> New-FreeIPAServiceApp -UseSecretStore -Force

        DESCRIPTION: Replaces the account and keeps the new password in the vault
        OUTPUT: The banner, and the result under -PassThru
        USE CASE: Rotating the password, or moving it off disk on a shared machine

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialPath',
        Justification = 'A file path to a credential record, not a credential.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'The handover banner is an instruction to the person who just bootstrapped, naming the exact command to connect with from now on. It must survive a caller who is capturing the output.')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [string]$CredentialPath,

        [Parameter()]
        [switch]$UseSecretStore,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$VaultName = 'FreeIPAEnvironment',

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

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-FreeIPAConnection
    $marker = Get-FreeIPASeedMarker -Connection $connection
    $username = Get-FreeIPAServiceAccountName -Marker $marker
    $recordPath = Get-FreeIPACredentialPath -BaseUrl $connection.BaseUrl -Path $CredentialPath
    $adminGroup = 'admins'
    $warnings = @()

    # --- An existing account ----------------------------------------------------------------
    $existing = Invoke-FreeIPARequest -Method 'user_show' -Arguments $username -IgnoreError 'NotFound' -Connection $connection

    if ($existing -and -not $Force) {
        throw "'$username' already exists. Use -Force to replace it and mint a new password, or Get-TestServiceApp to see what is stored."
    }

    if (-not $PSCmdlet.ShouldProcess($username, "Create an admins-group service account with a random password")) {
        return
    }

    # --- The vault, before anything is created ----------------------------------------------
    if ($UseSecretStore) {
        $vault = Initialize-TestSecretVault -VaultName $VaultName -VaultPassword $VaultPassword -Install
        if (-not $vault -or -not $vault.Available) {
            throw "Vault '$VaultName' is not usable, so nothing was created."
        }
    }

    if ($existing) {
        Write-Verbose "Replacing service account $username"
        $null = Invoke-FreeIPARequest -Method 'user_del' -Arguments $username -Connection $connection
    }

    # --- Create ------------------------------------------------------------------------------
    # The server mints the first password and hands it back once. It is expired on arrival,
    # as every admin-set password is, so it is changed immediately as the user.
    $created = Invoke-FreeIPARequest -Method 'user_add' -Arguments $username -Connection $connection -Options @{
        givenname   = 'TestEnvironment'
        sn          = 'Automation'
        cn          = '{0}Automation' -f $marker.Prefix
        displayname = '{0}Automation' -f $marker.Prefix
        userclass   = @($marker.Tag)
        loginshell  = '/sbin/nologin'
        random      = $true
    }
    if (-not $created -or -not $created.result -or -not $created.result.randompassword) {
        throw 'FreeIPA created the service account but returned no password.'
    }
    $initial = [string]$created.result.randompassword

    $password = New-TestPassword -Length 32
    Set-FreeIPAPassword -Connection $connection -Username $username -OldPassword $initial -NewPassword $password -Confirm:$false

    # Far enough out that the realm's policy never expires it underneath a run. Connect still
    # rotates on 'password-expired' should an administrator shorten it by hand.
    try {
        $null = Invoke-FreeIPARequest -Method 'user_mod' -Arguments $username -Connection $connection -Options @{
            krbpasswordexpiration = ConvertTo-FreeIPADateTime -Value ([DateTimeOffset]::UtcNow.AddYears(10))
        }
    }
    catch {
        $warnings += "Could not extend the password expiry: $($_.Exception.Message). The realm's policy applies and the connect will rotate the password when it expires."
        Write-Warning $warnings[-1]
    }

    # --- Rights ------------------------------------------------------------------------------
    try {
        $membership = Invoke-FreeIPARequest -Method 'group_add_member' -Arguments $adminGroup -Connection $connection -Options @{ user = @($username) }
        if ($membership -and $membership.completed -ne 1) {
            throw "the realm reported $($membership.completed) completed additions"
        }
    }
    catch {
        $warnings += "Could not add the account to '$adminGroup': $($_.Exception.Message). It exists but cannot seed anything until it has rights."
        Write-Warning $warnings[-1]
    }

    # --- The record --------------------------------------------------------------------------
    $exportArgs = @{
        Path           = $recordPath
        BaseUrl        = $connection.BaseUrl
        Username       = $username
        Password       = $password
        CaCertificate  = $connection.CaCertificate
        UseSecretStore = $UseSecretStore
        VaultName      = $VaultName
        Confirm        = $false
    }
    if ($VaultPassword) { $exportArgs['VaultPassword'] = $VaultPassword }
    $stored = Export-FreeIPACredential @exportArgs

    # --- Handover ------------------------------------------------------------------------------
    $handover = $false
    $probeHttp = $null
    try {
        $probeHttp = New-FreeIPAHttpClient -BaseUrl $connection.BaseUrl -CaCertificate $connection.CaCertificate
        $probe = @{
            BaseUrl = $connection.BaseUrl; Username = $username; Password = $password; AuthType = 'ServiceAccount'
            Client = $probeHttp.Client; Cookies = $probeHttp.Cookies; ApiVersion = $connection.ApiVersion
        }
        $login = Connect-FreeIPASession -Connection $probe
        if (-not $login.Success) { throw "login was rejected ($($login.Reason))" }
        $who = Invoke-FreeIPARequest -Method 'whoami' -Connection $probe
        $handover = [bool]($who -and $who.arguments -and (@($who.arguments)[0]) -eq $username)
    }
    catch {
        $warnings += "The account was created but its password did not authenticate: $($_.Exception.Message)"
        Write-Warning $warnings[-1]
    }
    finally {
        if ($probeHttp) { $probeHttp.Client.Dispose() }
    }

    Write-Host ''
    Write-Host ' Bootstrap complete. From now on, connect as the service account:' -ForegroundColor Green
    Write-Host ''
    Write-Host " Connect-TestEnvironment -Provider FreeIPA -BaseUrl $($connection.BaseUrl) -ServiceAccount" -ForegroundColor Cyan
    Write-Host ''
    if ($UseSecretStore) {
        Write-Host " (password in vault '$VaultName'; the account comes from the record at $recordPath)" -ForegroundColor DarkGray
    }
    else {
        Write-Host " (password $($stored.Protection)-protected in the record at $recordPath)" -ForegroundColor DarkGray
    }
    Write-Host ''

    if ($PassThru) {
        return [PSCustomObject]@{
            PSTypeName       = 'FreeIPAServiceApp'
            BaseUrl          = $connection.BaseUrl
            Username         = $username
            AdminGroup       = $adminGroup
            CredentialPath   = $recordPath
            Protection       = $stored.Protection
            VaultName        = $stored.VaultName
            HandoverVerified = $handover
            Warnings         = $warnings
        }
    }
}