Providers/Authentik/Public/New-AuthentikServiceApp.ps1
|
function New-AuthentikServiceApp { <# .SYNOPSIS Creates the automation service account the module connects as from then on .DESCRIPTION Authentik's equivalent of a service app is a service account: a user of the service_account type carrying a token. The account-creation call returns a token of its own, but that one has the app-password intent, which the API refuses as a bearer credential; a live run proved it with 'Token invalid/expired'. So this creates the account, replaces that token with one of the api intent and no expiry, reads its key back, places the account under the seed path with the seed tag, adds it to the instance's superuser group so it can create and delete everything the seed touches, proves the token by calling the API with it, and writes the credential record that Connect-AuthentikEnvironment -ServiceAccount reads. The token 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 token, 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 token 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 token. .PARAMETER PassThru Returns the result object. .OUTPUTS PSCustomObject with BaseUrl, Username, UserPk, SuperuserGroup, CredentialPath, Protection, VaultName, HandoverVerified and Warnings. .EXAMPLE PS> New-AuthentikServiceApp 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 instance, after connecting with an API token .EXAMPLE PS> New-AuthentikServiceApp -UseSecretStore -Force DESCRIPTION: Replaces the account and keeps the new token in the vault OUTPUT: The banner, and the result under -PassThru USE CASE: Rotating the token, 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 = 'AuthentikEnvironment', [Parameter()] [System.Security.SecureString]$VaultPassword, [Parameter()] [switch]$Force, [Parameter()] [switch]$PassThru ) $connection = Get-AuthentikConnection $marker = Get-AuthentikSeedMarker -Connection $connection $username = Get-AuthentikServiceAccountName -Marker $marker $recordPath = Get-AuthentikCredentialPath -BaseUrl $connection.BaseUrl -Path $CredentialPath $warnings = @() # --- An existing account ---------------------------------------------------------------- $existing = @(Invoke-AuthentikRequest -Method GET -Path '/core/users/' ` -Query @{ username = $username } -Connection $connection -Paginate) if ($existing.Count -gt 0 -and -not $Force) { throw "'$username' already exists (pk $($existing[0].pk)). Use -Force to replace it and mint a new token, or Get-TestServiceApp to see what is stored." } if (-not $PSCmdlet.ShouldProcess($username, 'Create a superuser service account with a non-expiring api-intent token')) { 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.Count -gt 0) { Write-Verbose "Replacing service account $username (pk $($existing[0].pk))" $null = Invoke-AuthentikRequest -Method DELETE -Path "/core/users/$($existing[0].pk)/" -Connection $connection } # --- Create ------------------------------------------------------------------------------ $created = Invoke-AuthentikRequest -Method POST -Path '/core/users/service_account/' -Connection $connection -Body @{ name = $username create_group = $false expiring = $false } if (-not $created -or -not $created.user_pk) { throw 'Authentik created the service account but returned no user.' } $userPk = [int]$created.user_pk # The creation call's token has the app_password intent and is refused as a bearer # credential. It is removed rather than left as an unused way in, and an api-intent # token is minted in its place, whose key the API hands back exactly once through # view_key. try { $leftover = @(Invoke-AuthentikRequest -Method GET -Path '/core/tokens/' ` -Query @{ user__username = $username; intent = 'app_password' } -Connection $connection -Paginate) foreach ($item in $leftover) { $null = Invoke-AuthentikRequest -Method DELETE -Path "/core/tokens/$($item.identifier)/" -Connection $connection } } catch { Write-Verbose "Could not remove the app-password token: $($_.Exception.Message)" } $tokenIdentifier = '{0}-api' -f $username $null = Invoke-AuthentikRequest -Method POST -Path '/core/tokens/' -Connection $connection -Body @{ identifier = $tokenIdentifier intent = 'api' user = $userPk expiring = $false description = 'TestEnvironment automation. Safe to delete; teardown removes it with the account.' } $key = Invoke-AuthentikRequest -Method GET -Path "/core/tokens/$tokenIdentifier/view_key/" -Connection $connection if (-not $key -or -not $key.key) { throw "Authentik created the token '$tokenIdentifier' but did not return its key." } $token = [string]$key.key # Stamped like every other seeded user, so it is found by the same evidence and kept # apart only by its reserved username. $attributes = [ordered]@{} $attributes[$marker.Attribute] = $marker.Tag $attributes['labKey'] = $username $null = Invoke-AuthentikRequest -Method PATCH -Path "/core/users/$userPk/" -Connection $connection -Body @{ name = '{0}Automation' -f $marker.Prefix path = $marker.UserPath attributes = $attributes } # --- Rights ------------------------------------------------------------------------------ $superuserGroup = $null try { $superusers = @(Invoke-AuthentikRequest -Method GET -Path '/core/groups/' ` -Query @{ is_superuser = 'true' } -Connection $connection -Paginate) if ($superusers.Count -eq 0) { throw 'no superuser group exists in this instance' } $superuserGroup = $superusers[0] $null = Invoke-AuthentikRequest -Method POST -Path "/core/groups/$($superuserGroup.pk)/add_user/" ` -Body @{ pk = $userPk } -Connection $connection } catch { $warnings += "Could not add the account to a superuser group: $($_.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 UserPk = $userPk Token = $token UseSecretStore = $UseSecretStore VaultName = $VaultName Confirm = $false } if ($VaultPassword) { $exportArgs['VaultPassword'] = $VaultPassword } $stored = Export-AuthentikCredential @exportArgs # --- Handover ------------------------------------------------------------------------------ $handover = $false try { $probe = @{ BaseUrl = $connection.BaseUrl; AuthorizationHeader = "Bearer $token"; AuthType = 'ServiceAccount' } $me = Invoke-AuthentikRequest -Method GET -Path '/core/users/me/' -Connection $probe $handover = [bool]($me -and $me.user -and $me.user.username -eq $username) } catch { $warnings += "The account was created but its token did not authenticate: $($_.Exception.Message)" Write-Warning $warnings[-1] } Write-Host '' Write-Host ' Bootstrap complete. From now on, connect as the service account:' -ForegroundColor Green Write-Host '' Write-Host " Connect-TestEnvironment -Provider Authentik -BaseUrl $($connection.BaseUrl) -ServiceAccount" -ForegroundColor Cyan Write-Host '' if ($UseSecretStore) { Write-Host " (token in vault '$VaultName'; the account comes from the record at $recordPath)" -ForegroundColor DarkGray } else { Write-Host " (token $($stored.Protection)-protected in the record at $recordPath)" -ForegroundColor DarkGray } Write-Host '' if ($PassThru) { return [PSCustomObject]@{ PSTypeName = 'AuthentikServiceApp' BaseUrl = $connection.BaseUrl Username = $username UserPk = $userPk SuperuserGroup = $(if ($superuserGroup) { $superuserGroup.name } else { $null }) CredentialPath = $recordPath Protection = $stored.Protection VaultName = $stored.VaultName HandoverVerified = $handover Warnings = $warnings } } } |