Providers/Authentik/Public/New-AuthentikFlow.ps1
|
function New-AuthentikFlow { <# .EXTERNALHELP TestEnvironment-Help.xml .SYNOPSIS Creates the seeded stages and flows from Data\AuthentikStages.csv and Data\AuthentikFlows.csv, and attaches the flows to seeded providers #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] [OutputType([PSCustomObject])] param( [Parameter()] [string[]]$FlowName, [Parameter()] [switch]$SkipProvider, [Parameter()] [switch]$PassThru ) $connection = Get-AuthentikConnection $marker = Get-AuthentikSeedMarker -Connection $connection $dataPath = Get-AuthentikDataPath $flowRows = @(Import-Csv -Path (Join-Path -Path $dataPath -ChildPath 'AuthentikFlows.csv') -Encoding UTF8) $stageRows = @(Import-Csv -Path (Join-Path -Path $dataPath -ChildPath 'AuthentikStages.csv') -Encoding UTF8) if ($FlowName) { $flowRows = @($flowRows | Where-Object { $FlowName -contains $_.Name }) $unknown = @($FlowName | Where-Object { $flowRows.Name -notcontains $_ }) if ($unknown) { throw "No definition in AuthentikFlows.csv for: $($unknown -join ', ')" } $wantedStages = @($flowRows | ForEach-Object { $_.Stages -split ';' } | Where-Object { $_ } | Sort-Object -Unique) $stageRows = @($stageRows | Where-Object { $wantedStages -contains $_.Name }) } # Each stage type has its own endpoint for create and update and one shared endpoint for # listing and deleting, the same shape as the policies. $stagePath = @{ Identification = 'identification' Password = 'password' UserLogin = 'user_login' Consent = 'consent' AuthenticatorValidate = 'authenticator/validate' Deny = 'deny' } $stageTypeByModel = @{ 'authentik_stages_identification.identificationstage' = 'Identification' 'authentik_stages_password.passwordstage' = 'Password' 'authentik_stages_user_login.userloginstage' = 'UserLogin' 'authentik_stages_consent.consentstage' = 'Consent' 'authentik_stages_authenticator_validate.authenticatorvalidatestage' = 'AuthenticatorValidate' 'authentik_stages_deny.denystage' = 'Deny' } # The flow field a provider holds for each designation, and the endpoint each provider # type is edited at. $flowFieldByDesignation = @{ authentication = 'authentication_flow'; authorization = 'authorization_flow'; invalidation = 'invalidation_flow' } # Settings the API wants as a list even when the CSV holds one item, which the parser # would otherwise hand over as a string. $listSettings = @('backends', 'user_fields', 'device_classes', 'sources', 'configuration_stages', 'webauthn_allowed_device_types', 'webauthn_hints') # A proxy provider is validated whole on every update, so a partial PATCH that carries # only the flow is refused for the hosts it does not mention. These come back from a GET. $providerFieldsToResend = @{ proxy = @('external_host', 'internal_host', 'mode', 'internal_host_ssl_validation') } $providerPathByComponent = @{ 'ak-provider-oauth2-form' = 'oauth2' 'ak-provider-proxy-form' = 'proxy' 'ak-provider-saml-form' = 'saml' 'ak-provider-ldap-form' = 'ldap' 'ak-provider-radius-form' = 'radius' } $result = [PSCustomObject]@{ TotalFlows = $flowRows.Count CreatedFlows = 0 UpdatedFlows = 0 StagesCreated = 0 StagesUpdated = 0 BindingsCreated = 0 ProvidersUpdated = 0 Flows = @() Errors = @() } $existingStageByName = @{} foreach ($existing in (Get-AuthentikSeededObject -Type Stages -Connection $connection)) { $existingStageByName[[string]$existing.name] = $existing } $existingFlowBySlug = @{} foreach ($existing in (Get-AuthentikSeededObject -Type Flows -Connection $connection)) { $existingFlowBySlug[[string]$existing.slug] = $existing } $applicationBySlug = @{} if (-not $SkipProvider) { foreach ($application in (Get-AuthentikSeededObject -Type Applications -Connection $connection)) { $applicationBySlug[[string]$application.slug] = $application } } # --- Stages ------------------------------------------------------------------------------ $stagePkByKey = @{} foreach ($row in $stageRows) { $name = '{0}{1}' -f $marker.Prefix, $row.DisplayName if (-not $stagePath.ContainsKey($row.Type)) { $message = "Stage '$name' has type '$($row.Type)', which the seed cannot create. Skipped." $result.Errors += $message Write-Warning $message continue } if (-not $PSCmdlet.ShouldProcess($name, "Create Authentik $($row.Type) stage")) { continue } try { $body = @{ name = $name } $settings = ConvertFrom-AuthentikSetting -Text $row.Settings foreach ($key in $settings.Keys) { $value = $settings[$key] # Assigned as a variable, not from an if expression, because the pipeline # would unroll a one-element array back into the scalar the API refuses. if ($listSettings -contains $key) { $value = [object[]]@($value) } $body[$key] = $value } $stage = $null if ($existingStageByName.ContainsKey($name)) { $existing = $existingStageByName[$name] $existingType = $stageTypeByModel[[string]$existing.meta_model_name] if ($existingType -and $existingType -ne $row.Type) { throw "A stage named '$name' already exists as a $existingType stage; the seed defines it as $($row.Type). Remove it first." } $stage = Invoke-AuthentikRequest -Method PATCH -Path "/stages/$($stagePath[$row.Type])/$($existing.pk)/" -Body $body -Connection $connection $result.StagesUpdated++ Write-Verbose "Updated $($row.Type) stage $name" } else { $stage = Invoke-AuthentikRequest -Method POST -Path "/stages/$($stagePath[$row.Type])/" -Body $body -Connection $connection $result.StagesCreated++ Write-Verbose "Created $($row.Type) stage $name" } $stagePkByKey[$row.Name] = [string]$stage.pk } catch { $message = "Failed to create stage '$name': $($_.Exception.Message)" $result.Errors += $message Write-Error $message } } # --- Flows, their stage bindings, and the providers they are attached to ---------------- $flows = [System.Collections.Generic.List[object]]::new() foreach ($row in $flowRows) { $slug = '{0}-{1}' -f $marker.SlugPrefix, $row.Slug $name = '{0}{1}' -f $marker.Prefix, $row.Title if (-not $PSCmdlet.ShouldProcess("$name ($slug)", "Create Authentik $($row.Designation) flow")) { continue } try { $body = @{ name = $name slug = $slug title = $row.Title designation = $row.Designation layout = $(if ($row.Layout) { $row.Layout } else { 'stacked' }) policy_engine_mode = 'any' denied_action = 'message_continue' } $flow = $null if ($existingFlowBySlug.ContainsKey($slug)) { $flow = Invoke-AuthentikRequest -Method PATCH -Path "/flows/instances/$slug/" -Body $body -Connection $connection $result.UpdatedFlows++ Write-Verbose "Updated flow $slug" } else { $flow = Invoke-AuthentikRequest -Method POST -Path '/flows/instances/' -Body $body -Connection $connection $result.CreatedFlows++ Write-Verbose "Created flow $slug" } # Stage bindings, in the CSV's order, ten apart so one can be slotted in by hand. The # order comes from the CSV position, not from what was created, so a stage that # failed on one run and exists on the next lands where the CSV puts it, and an # existing binding that has drifted is moved rather than duplicated. $existingBindings = @(Invoke-AuthentikRequest -Method GET -Path '/flows/bindings/' ` -Query @{ target = [string]$flow.pk } -Connection $connection -Paginate) $boundStages = @() $stageKeys = @($row.Stages -split ';' | Where-Object { $_ }) for ($index = 0; $index -lt $stageKeys.Count; $index++) { $stageKey = $stageKeys[$index] $order = $index * 10 if (-not $stagePkByKey.ContainsKey($stageKey)) { $message = "Flow '$slug' binds stage '$stageKey', which does not exist. Skipped." $result.Errors += $message Write-Warning $message continue } $stagePk = $stagePkByKey[$stageKey] $existingBinding = @($existingBindings | Where-Object { [string]$_.stage -eq $stagePk }) if ($existingBinding.Count -eq 0) { $null = Invoke-AuthentikRequest -Method POST -Path '/flows/bindings/' -Connection $connection -Body @{ target = [string]$flow.pk stage = $stagePk order = $order } $result.BindingsCreated++ } elseif ([int]$existingBinding[0].order -ne $order) { $null = Invoke-AuthentikRequest -Method PATCH -Path "/flows/bindings/$($existingBinding[0].pk)/" ` -Body @{ order = $order } -Connection $connection } $boundStages += $stageKey } # Attach to the providers behind the named applications, at the field the flow's # designation dictates. Only a seeded application ever resolves here, so the seed # can only ever change a provider it created. $attached = @() if (-not $SkipProvider -and $flowFieldByDesignation.ContainsKey($row.Designation)) { $field = $flowFieldByDesignation[$row.Designation] foreach ($applicationKey in @($row.Applications -split ';' | Where-Object { $_ })) { $applicationSlug = '{0}-{1}' -f $marker.SlugPrefix, $applicationKey $application = $applicationBySlug[$applicationSlug] $component = if ($application -and $application.provider_obj) { [string]$application.provider_obj.component } else { '' } if (-not $application -or -not $application.provider -or -not $providerPathByComponent.ContainsKey($component)) { $message = "Flow '$slug' names application '$applicationSlug', which does not exist or has no provider the seed can edit. Skipped." $result.Errors += $message Write-Warning $message continue } $providerType = $providerPathByComponent[$component] $patch = @{ $field = [string]$flow.pk } if ($providerFieldsToResend.ContainsKey($providerType)) { $current = Invoke-AuthentikRequest -Method GET -Path "/providers/$providerType/$($application.provider)/" -Connection $connection foreach ($resend in $providerFieldsToResend[$providerType]) { if ($current.PSObject.Properties[$resend]) { $patch[$resend] = $current.$resend } } } $null = Invoke-AuthentikRequest -Method PATCH -Path "/providers/$providerType/$($application.provider)/" ` -Body $patch -Connection $connection $result.ProvidersUpdated++ $attached += $applicationSlug } } elseif (-not $SkipProvider -and $row.Applications) { $message = "Flow '$slug' is a $($row.Designation) flow, which no provider holds a field for. Not attached." $result.Errors += $message Write-Warning $message } $flows.Add([PSCustomObject]@{ Id = [string]$flow.pk Key = $row.Name Name = $name Slug = $slug Designation = $row.Designation Stages = $boundStages Applications = $attached }) } catch { $message = "Failed to create flow '$slug': $($_.Exception.Message)" $result.Errors += $message Write-Error $message } } $result.Flows = $flows.ToArray() Write-Verbose ("Flows: $($result.CreatedFlows) created, $($result.UpdatedFlows) updated, " + "$($result.StagesCreated) stages created, $($result.BindingsCreated) bindings, " + "$($result.ProvidersUpdated) providers updated, $($result.Errors.Count) problems") if ($PassThru) { return $result } } |