Providers/Authentik/Public/Remove-AuthentikEnvironment.ps1

function Remove-AuthentikEnvironment {
    <#
    .SYNOPSIS
        Removes everything the seed created, proving ownership of each object first

    .DESCRIPTION
        Tears down in the reverse of the order the seed built: policy bindings and policies,
        then applications and the providers behind them, then users, then groups deepest
        first, then notification rules and their transports. Nothing is deleted for merely
        carrying the prefix. Each type has to satisfy the evidence the seed wrote - the tag in
        a user's or group's attributes, the marker in an application's description, a
        provider's attachment to a seeded application - and Get-AuthentikSeededObject is the
        one place that evidence is judged.

        The automation service account is a seeded user and is the one that must not be
        deleted while it is the credential in use, so it is kept unless -RemoveServiceAccount
        is passed, and then removed last. Its credential record goes with it only under
        -RemoveCredentialFile, and the vault secret the record names is read before the file
        is deleted, or the secret is orphaned.

        -WhatIf wins over -Force. -Force suppresses the prompts by setting the confirm
        preference rather than by bypassing ShouldProcess, so ShouldProcess still runs and
        still returns false under -WhatIf. That distinction is pinned by the tests because
        -Force defeating -WhatIf was the worst defect an earlier module shipped.

    .PARAMETER Keep
        Object types to leave in place: Policies, Applications, Users, Groups,
        NotificationRules.

    .PARAMETER RemoveServiceAccount
        Also delete the automation service account. It is removed last, after everything it
        was used to remove.

    .PARAMETER RemoveCredentialFile
        With -RemoveServiceAccount, also delete the credential record and the vault secret it
        names.

    .PARAMETER Force
        Do not prompt. Has no effect under -WhatIf.

    .PARAMETER PassThru
        Returns the result object.

    .OUTPUTS
        PSCustomObject with BaseUrl, Prefix, StartTime, EndTime and a Removed and Errors list
        per object type.

    .EXAMPLE
        PS> Remove-AuthentikEnvironment -WhatIf

        DESCRIPTION: Lists everything that would be removed
        OUTPUT: One WhatIf line per object the module can prove it owns
        USE CASE: Always the first teardown call

    .EXAMPLE
        PS> Remove-AuthentikEnvironment -Keep Groups -Force -PassThru

        DESCRIPTION: Removes everything except the groups, without prompting
        OUTPUT: The result object with counts per type
        USE CASE: Re-seeding users against groups a report was already written against

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


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'The teardown summary is written for the person watching; the result object carries the same data.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Keep',
        Justification = 'Read inside the per-type sweep, which the analyzer does not follow.')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateSet('Invitations', 'Tokens', 'Bindings', 'Policies', 'Entitlements', 'Outposts', 'Applications',
            'ScopeMappings', 'Certificates', 'Flows', 'Roles', 'Users', 'Groups', 'NotificationRules')]
        [string[]]$Keep = @(),

        [Parameter()]
        [switch]$RemoveServiceAccount,

        [Parameter()]
        [switch]$RemoveCredentialFile,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-AuthentikConnection
    $marker = Get-AuthentikSeedMarker -Connection $connection

    $results = [PSCustomObject]@{
        BaseUrl           = $connection.BaseUrl
        Prefix            = $connection.Prefix
        StartTime         = Get-Date
        EndTime           = $null
        Invitations       = @{ Removed = @(); Errors = @() }
        Tokens            = @{ Removed = @(); Errors = @() }
        Bindings          = @{ Removed = @(); Errors = @() }
        Policies          = @{ Removed = @(); Errors = @() }
        Entitlements      = @{ Removed = @(); Errors = @() }
        Outposts          = @{ Removed = @(); Errors = @() }
        Applications      = @{ Removed = @(); Errors = @() }
        Providers         = @{ Removed = @(); Errors = @() }
        ScopeMappings     = @{ Removed = @(); Errors = @() }
        Certificates      = @{ Removed = @(); Errors = @() }
        Flows             = @{ Removed = @(); Errors = @() }
        Stages            = @{ Removed = @(); Errors = @() }
        Roles             = @{ Removed = @(); Errors = @() }
        Users             = @{ Removed = @(); Errors = @() }
        Groups            = @{ Removed = @(); Errors = @() }
        NotificationRules = @{ Removed = @(); Errors = @() }
        ServiceAccount    = @{ Removed = @(); Errors = @() }
    }

    Write-TestMessage -Message "Authentik Test Environment Teardown ($($connection.BaseUrl))" -Type Header

    # Read directly rather than through ShouldProcess alone, so -Force can skip the prompt
    # without also skipping the preview.
    $isWhatIf = $WhatIfPreference

    # -Force sets the preference rather than short-circuiting ShouldProcess, which is what
    # keeps -WhatIf working: ShouldProcess is still called and still returns false under it.
    if ($Force -and -not $isWhatIf) {
        $ConfirmPreference = 'None'
    }

    if (-not $Force -and -not $isWhatIf) {
        $prompt = ("This permanently deletes every user, group, role, application, provider, outpost, certificate, " +
            "flow, stage, scope mapping, entitlement, policy, binding, token, invitation and notification rule " +
            "tagged '$($marker.Tag)' in $($connection.BaseUrl). Authentik has no undo.")
        if (-not $PSCmdlet.ShouldContinue($prompt, 'Remove Authentik test environment')) {
            Write-TestMessage -Message 'Teardown cancelled.' -Type Warning
            if ($PassThru) { return $results }
            return
        }
    }

    # A sweep is: find what we own, confirm each, delete each, record the outcome. The
    # deletion path takes the object's key so each type can name its own.
    $sweep = {
        param($key, $label, $one, $items, $nameOf, $pathOf)
        Write-TestMessage -Message "Removing $label" -Type Info
        foreach ($item in $items) {
            $name = & $nameOf $item
            if (-not $PSCmdlet.ShouldProcess($name, "Delete Authentik $one")) { continue }
            try {
                $null = Invoke-AuthentikRequest -Method DELETE -Path (& $pathOf $item) -Connection $connection
                $results.$key.Removed += $name
            }
            catch {
                $results.$key.Errors += "${name}: $($_.Exception.Message)"
                Write-Error "Failed to delete '$name': $($_.Exception.Message)"
            }
        }
    }

    # Authentik gives some users a hidden role of their own, named ak-managed-role--user-<pk>,
    # to carry object permissions: every outpost's service account has one, for instance.
    # Deleting the user does not delete the role, and nothing else ever names it, so for a
    # user the seed owns it is ours by construction and is removed before the user is. Before,
    # because for the service account the user is also the credential this session runs on.
    $removeManagedRole = {
        param($userList)
        if ($userList.Count -eq 0) { return }
        $wanted = @{}
        foreach ($u in $userList) { $wanted['ak-managed-role--user-{0}' -f $u.pk] = $u.username }
        $roles = @(Invoke-AuthentikRequest -Method GET -Path '/rbac/roles/' `
                -Query @{ search = 'ak-managed-role--user-' } -Connection $connection -Paginate |
                Where-Object { $wanted.ContainsKey([string]$_.name) })
        & $sweep 'Roles' 'the hidden per-user roles Authentik made' 'managed user role' $roles `
            { param($r) '{0} ({1})' -f $r.name, $wanted[[string]$r.name] } { param($r) "/rbac/roles/$($r.pk)/" }
    }

    # --- 1. Invitations and tokens, which depend on nothing ---------------------------------
    if ('Invitations' -notin $Keep) {
        try {
            $invitations = @(Get-AuthentikSeededObject -Type Invitations -Connection $connection)
            & $sweep 'Invitations' 'invitations' 'invitation' $invitations { param($i) $i.name } { param($i) "/stages/invitation/invitations/$($i.pk)/" }
        }
        catch {
            $results.Invitations.Errors += $_.Exception.Message
            Write-Error "Could not enumerate invitations: $($_.Exception.Message)"
        }
    }

    if ('Tokens' -notin $Keep) {
        try {
            $tokens = @(Get-AuthentikSeededObject -Type Tokens -Connection $connection)
            & $sweep 'Tokens' 'tokens' 'token' $tokens { param($t) $t.identifier } { param($t) "/core/tokens/$($t.identifier)/" }
        }
        catch {
            $results.Tokens.Errors += $_.Exception.Message
            Write-Error "Could not enumerate tokens: $($_.Exception.Message)"
        }
    }

    # --- 2. Bindings on every seeded target --------------------------------------------------
    # A binding on a seeded application, entitlement or rule is ours whatever it carries, and
    # removing them first means a policy or group that is kept is cleanly detached rather than
    # left pointing at a target that is about to go.
    if ('Bindings' -notin $Keep) {
        try {
            $bindingTargets = @()
            foreach ($application in @(Get-AuthentikSeededObject -Type Applications -Connection $connection)) {
                $bindingTargets += @{ Uuid = [string]$application.pbm_uuid; Name = $application.slug }
            }
            foreach ($entitlement in @(Get-AuthentikSeededObject -Type Entitlements -Connection $connection)) {
                $bindingTargets += @{ Uuid = [string]$entitlement.pbm_uuid; Name = $entitlement.name }
            }
            foreach ($rule in @(Get-AuthentikSeededObject -Type NotificationRules -Connection $connection)) {
                $bindingTargets += @{ Uuid = [string]$rule.pk; Name = $rule.name }
            }
            foreach ($bindingTarget in $bindingTargets) {
                $bindings = @(Invoke-AuthentikRequest -Method GET -Path '/policies/bindings/' `
                        -Query @{ target = $bindingTarget.Uuid } -Connection $connection -Paginate)
                $targetName = $bindingTarget.Name
                & $sweep 'Bindings' "bindings on $targetName" 'policy binding' $bindings `
                    { param($b) 'binding {0} on {1}' -f $b.pk, $targetName } `
                    { param($b) "/policies/bindings/$($b.pk)/" }
            }
        }
        catch {
            $results.Bindings.Errors += $_.Exception.Message
            Write-Error "Could not enumerate bindings: $($_.Exception.Message)"
        }
    }

    # --- 3. Policies, then entitlements ------------------------------------------------------
    if ('Policies' -notin $Keep) {
        try {
            $policies = @(Get-AuthentikSeededObject -Type Policies -Connection $connection)
            & $sweep 'Policies' 'policies' 'policy' $policies { param($p) $p.name } { param($p) "/policies/all/$($p.pk)/" }
        }
        catch {
            $results.Policies.Errors += $_.Exception.Message
            Write-Error "Could not enumerate policies: $($_.Exception.Message)"
        }
    }

    if ('Entitlements' -notin $Keep) {
        try {
            $entitlements = @(Get-AuthentikSeededObject -Type Entitlements -Connection $connection)
            & $sweep 'Entitlements' 'application entitlements' 'application entitlement' $entitlements `
                { param($e) '{0} on {1}' -f $e.name, $e.app_slug } { param($e) "/core/application_entitlements/$($e.pbm_uuid)/" }
        }
        catch {
            $results.Entitlements.Errors += $_.Exception.Message
            Write-Error "Could not enumerate entitlements: $($_.Exception.Message)"
        }
    }

    # --- 4. Outposts, then applications, the providers behind them, then what they carried ---
    # An outpost holds its providers, so it goes before them; a certificate is held by a
    # provider, so it goes after.
    if ('Outposts' -notin $Keep) {
        try {
            $outposts = @(Get-AuthentikSeededObject -Type Outposts -Connection $connection)

            # Every outpost has a service account of its own, named ak-outpost-<uuid>, which
            # Authentik deletes with the outpost and whose hidden role it does not. Found by
            # the name the outpost's own uuid dictates, so it is ours by construction.
            if ('Roles' -notin $Keep) {
                $outpostUsers = foreach ($outpost in $outposts) {
                    $username = 'ak-outpost-{0}' -f (([string]$outpost.pk) -replace '-', '')
                    @(Invoke-AuthentikRequest -Method GET -Path '/core/users/' `
                            -Query @{ username = $username } -Connection $connection -Paginate) | Where-Object { $_.username -eq $username }
                }
                & $removeManagedRole @($outpostUsers)
            }

            & $sweep 'Outposts' 'outposts' 'outpost' $outposts { param($o) $o.name } { param($o) "/outposts/instances/$($o.pk)/" }
        }
        catch {
            $results.Outposts.Errors += $_.Exception.Message
            Write-Error "Could not enumerate outposts: $($_.Exception.Message)"
        }
    }

    if ('Applications' -notin $Keep) {
        try {
            $applications = @(Get-AuthentikSeededObject -Type Applications -Connection $connection)
            & $sweep 'Applications' 'applications' 'application' $applications { param($a) $a.name } { param($a) "/core/applications/$($a.slug)/" }
            $providers = @(Get-AuthentikSeededObject -Type Providers -Connection $connection)
            & $sweep 'Providers' 'providers' 'provider' $providers { param($p) $p.name } { param($p) "/providers/all/$($p.pk)/" }
        }
        catch {
            $results.Applications.Errors += $_.Exception.Message
            Write-Error "Could not enumerate applications: $($_.Exception.Message)"
        }
    }

    if ('ScopeMappings' -notin $Keep) {
        try {
            $mappings = @(Get-AuthentikSeededObject -Type ScopeMappings -Connection $connection)
            & $sweep 'ScopeMappings' 'scope mappings' 'scope mapping' $mappings { param($m) $m.name } { param($m) "/propertymappings/provider/scope/$($m.pk)/" }
        }
        catch {
            $results.ScopeMappings.Errors += $_.Exception.Message
            Write-Error "Could not enumerate scope mappings: $($_.Exception.Message)"
        }
    }

    if ('Certificates' -notin $Keep) {
        try {
            $keypairs = @(Get-AuthentikSeededObject -Type Certificates -Connection $connection)
            & $sweep 'Certificates' 'certificates' 'certificate keypair' $keypairs { param($k) $k.name } { param($k) "/crypto/certificatekeypairs/$($k.pk)/" }
        }
        catch {
            $results.Certificates.Errors += $_.Exception.Message
            Write-Error "Could not enumerate certificates: $($_.Exception.Message)"
        }
    }

    # Flows after the providers that held them, because a provider's authorization flow is a
    # cascading reference: deleting the flow first would delete the provider with it, which is
    # only ours by luck. Stages after the flows that bound them.
    if ('Flows' -notin $Keep) {
        try {
            $flows = @(Get-AuthentikSeededObject -Type Flows -Connection $connection)
            & $sweep 'Flows' 'flows' 'flow' $flows { param($f) $f.slug } { param($f) "/flows/instances/$($f.slug)/" }
            $stages = @(Get-AuthentikSeededObject -Type Stages -Connection $connection)
            & $sweep 'Stages' 'stages' 'stage' $stages { param($s) $s.name } { param($s) "/stages/all/$($s.pk)/" }
        }
        catch {
            $results.Flows.Errors += $_.Exception.Message
            Write-Error "Could not enumerate flows: $($_.Exception.Message)"
        }
    }

    # --- 5. Roles, before the groups that hold them ------------------------------------------
    if ('Roles' -notin $Keep) {
        try {
            $roles = @(Get-AuthentikSeededObject -Type Roles -Connection $connection)
            & $sweep 'Roles' 'roles' 'role' $roles { param($r) $r.name } { param($r) "/rbac/roles/$($r.pk)/" }
        }
        catch {
            $results.Roles.Errors += $_.Exception.Message
            Write-Error "Could not enumerate roles: $($_.Exception.Message)"
        }
    }

    # --- 6. Users ----------------------------------------------------------------------------
    if ('Users' -notin $Keep) {
        try {
            $users = @(Get-AuthentikSeededObject -Type Users -Connection $connection)
            if ('Roles' -notin $Keep) { & $removeManagedRole $users }
            & $sweep 'Users' 'users' 'user' $users { param($u) $u.username } { param($u) "/core/users/$($u.pk)/" }
        }
        catch {
            $results.Users.Errors += $_.Exception.Message
            Write-Error "Could not enumerate users: $($_.Exception.Message)"
        }
    }

    # --- 7. Groups, deepest first ------------------------------------------------------------
    # A child names its parents, so removing the leaves first leaves nothing dangling if a
    # deletion midway fails.
    if ('Groups' -notin $Keep) {
        try {
            $groups = @(Get-AuthentikSeededObject -Type Groups -Connection $connection)

            # Depth is walked, not counted: a leaf and its parent both have one parent each.
            $byPk = @{}
            foreach ($group in $groups) { $byPk[[string]$group.pk] = $group }
            $depthOf = {
                param($group)
                $depth = 0
                $frontier = @([string[]]$group.parents)
                while ($frontier.Count -gt 0 -and $depth -lt 20) {
                    $depth++
                    $frontier = @($frontier | ForEach-Object { if ($byPk.ContainsKey($_)) { [string[]]$byPk[$_].parents } })
                }
                $depth
            }
            $ordered = @($groups | Sort-Object -Property @{ Expression = { & $depthOf $_ }; Descending = $true }, name)
            & $sweep 'Groups' 'groups' 'group' $ordered { param($g) $g.name } { param($g) "/core/groups/$($g.pk)/" }
        }
        catch {
            $results.Groups.Errors += $_.Exception.Message
            Write-Error "Could not enumerate groups: $($_.Exception.Message)"
        }
    }

    # --- 8. Notification rules, then their transports ---------------------------------------
    if ('NotificationRules' -notin $Keep) {
        try {
            $rules = @(Get-AuthentikSeededObject -Type NotificationRules -Connection $connection)
            & $sweep 'NotificationRules' 'notification rules' 'notification rule' $rules { param($r) $r.name } { param($r) "/events/rules/$($r.pk)/" }
            $transports = @(Get-AuthentikSeededObject -Type NotificationTransports -Connection $connection)
            & $sweep 'NotificationRules' 'notification transports' 'notification transport' $transports { param($t) $t.name } { param($t) "/events/transports/$($t.pk)/" }
        }
        catch {
            $results.NotificationRules.Errors += $_.Exception.Message
            Write-Error "Could not enumerate notification rules: $($_.Exception.Message)"
        }
    }

    # --- 9. The service account, last --------------------------------------------------------
    if ($RemoveServiceAccount) {
        try {
            $accountName = Get-AuthentikServiceAccountName -Marker $marker
            $account = @(Get-AuthentikSeededObject -Type Users -IncludeServiceAccount -Connection $connection |
                    Where-Object { $_.username -eq $accountName })

            if ($connection.AuthType -eq 'ServiceAccount' -and $account.Count -gt 0) {
                Write-Warning "Removing the service account this session is connected as. Nothing else will work afterwards until you reconnect with an API token."
            }

            if ('Roles' -notin $Keep) { & $removeManagedRole $account }
            & $sweep 'ServiceAccount' 'the service account' 'service account' $account { param($u) $u.username } { param($u) "/core/users/$($u.pk)/" }

            if ($RemoveCredentialFile) {
                $recordPath = Get-AuthentikCredentialPath -BaseUrl $connection.BaseUrl -Path $connection.CredentialPath
                if (Test-Path -LiteralPath $recordPath) {
                    if ($PSCmdlet.ShouldProcess($recordPath, 'Delete the service account credential record')) {
                        # The vault pointer is read before the file goes, or the secret is orphaned.
                        $record = $null
                        try { $record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json } catch { $record = $null }
                        if ($record -and $record.PSObject.Properties['secretName'] -and $record.secretName) {
                            try { Remove-TestVaultSecret -VaultName $record.vaultName -SecretName $record.secretName -Confirm:$false }
                            catch { Write-Warning "Could not remove the vault secret '$($record.secretName)': $($_.Exception.Message)" }
                        }
                        Remove-Item -LiteralPath $recordPath -Force
                        $results.ServiceAccount.Removed += $recordPath
                    }
                }
            }
        }
        catch {
            $results.ServiceAccount.Errors += $_.Exception.Message
            Write-Error "Could not remove the service account: $($_.Exception.Message)"
        }
    }

    $results.EndTime = Get-Date

    $tracked = @('Invitations', 'Tokens', 'Bindings', 'Policies', 'Entitlements', 'Outposts', 'Applications', 'Providers',
        'ScopeMappings', 'Certificates', 'Flows', 'Stages', 'Roles', 'Users', 'Groups', 'NotificationRules', 'ServiceAccount')
    $removedCount = @($tracked | ForEach-Object { @($results.$_.Removed).Count } | Measure-Object -Sum).Sum
    $errorCount = @($tracked | ForEach-Object { @($results.$_.Errors).Count } | Measure-Object -Sum).Sum

    Write-TestMessage -Message 'Teardown Summary' -Type Header
    Write-Host "Objects removed: $removedCount" -ForegroundColor Green
    if ($errorCount -gt 0) {
        Write-Warning "Failures: $errorCount. Inspect the results object with -PassThru."
    }

    if ($PassThru) { return $results }
}