Providers/Okta/Public/Remove-OktaEnvironment.ps1

function Remove-OktaEnvironment {
    <#
    .SYNOPSIS
        Removes everything this module created, in the order Okta will accept

    .DESCRIPTION
        Teardown is the half of a test environment that gets written badly, and on a tenant
        with ten user slots it is the half you run most often. Two things make it safe here.

        First, ownership is proven rather than assumed. Users must carry the labSeedTag
        attribute or sit under the seed email domain; groups must have both the name prefix
        and the seed marker in their description. Nothing is deleted because its name looked
        familiar.

        Second, the order is forced by Okta's own dependencies and runs in reverse of
        creation:

        1. Event hooks, trusted origins, policies and network zones. First, because Okta
           refuses to delete a zone that a policy rule still points at.
        2. Group rules, deactivated then deleted. A rule holding a group open will block the
           group's deletion.
        3. Users, deactivated then deleted. Okta needs both; a single DELETE on an active
           user only deactivates it.
        4. Groups.
        5. App integrations, deactivated then deleted. Kept separate from the service app,
           because -Keep ServiceApp is about retaining the credential you authenticate with
           and should not also strand eight lab apps you asked to be rid of.
        6. The service app, deactivated then deleted, and its private key with it.
        7. Linked object definitions. After the users, since removing a definition removes
           every link made with it.
        8. Custom profile attributes, nulled out of every schema. Removing an attribute that
           users still carry destroys their data, and it is what Get-OktaSeededUser
           identifies users by.
        9. User types. Genuinely last: a type cannot be deleted while a user is on it, and
           Okta returns a transient conflict for a while after its schema is touched, so this
           tolerates a retry.

        -WhatIf beats -Force. If both are passed nothing is deleted, because the only reason
        to pass -WhatIf is to find out what would happen.

    .PARAMETER Keep
        Components to leave alone. Valid values: UserTypes, Schema, Users, Groups, GroupRules,
        Apps, LinkedObjects, NetworkZones, Policies, TrustedOrigins, EventHooks, ServiceApp.

    .PARAMETER RemoveCredentialFile
        Also delete the local private key file. Off by default: the file is worthless once the
        app is gone, but deleting a key the user may have copied a path to is not something to
        do without being asked.

    .PARAMETER Force
        Skip the confirmation prompt. Has no effect under -WhatIf.

    .PARAMETER PassThru
        Return the detailed results object

    .OUTPUTS
        PSCustomObject with per-component Removed and Errors collections

    .EXAMPLE
        Remove-OktaEnvironment -WhatIf
        Lists every object that would be deleted. Worth running first, every time.

    .EXAMPLE
        Remove-OktaEnvironment -Force
        Tears the environment down without prompting

    .EXAMPLE
        Remove-OktaEnvironment -Keep ServiceApp, Schema -Force
        Clears the users, groups and rules but keeps the app you authenticate with, ready for
        an immediate re-seed

    .NOTES
        Author: Jeffrey Stuhr
        Version: 1.0.0
        Last Updated: 2026-08-07

        User deletion is irreversible. Okta has no recycle bin.

        If you are connected as the service app and do not use -Keep ServiceApp, the app
        deletes the credential it is authenticating with. That works, because the access token
        already issued stays valid for the rest of the run, but the next connection attempt
        will fail until a new app is created with an SSWS token.

    .LINK
        New-OktaEnvironment
        Connect-OktaEnvironment
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Colour-coded console progress is intentional; results are returned as objects.')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateSet('UserTypes', 'Schema', 'Users', 'Groups', 'GroupRules', 'Apps',
            'LinkedObjects', 'NetworkZones', 'Policies', 'TrustedOrigins', 'EventHooks',
            'ServiceApp')]
        [string[]]$Keep = @(),

        [Parameter()]
        [switch]$RemoveCredentialFile,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-OktaConnection

    $results = [PSCustomObject]@{
        OrgUrl     = $connection.OrgUrl
        Prefix     = $connection.Prefix
        StartTime  = Get-Date
        EndTime    = $null
        GroupRules = @{ Removed = @(); Errors = @() }
        Users      = @{ Removed = @(); Errors = @() }
        Groups     = @{ Removed = @(); Errors = @() }
        Apps           = @{ Removed = @(); Errors = @() }
        LinkedObjects  = @{ Removed = @(); Errors = @() }
        NetworkZones   = @{ Removed = @(); Errors = @() }
        Policies       = @{ Removed = @(); Errors = @() }
        TrustedOrigins = @{ Removed = @(); Errors = @() }
        EventHooks     = @{ Removed = @(); Errors = @() }
        UserTypes      = @{ Removed = @(); Errors = @() }
        ServiceApp = @{ Removed = @(); Errors = @() }
        Schema     = @{ Removed = @(); Errors = @() }
    }

    Write-TestMessage -Message "Okta Test Environment Teardown ($($connection.OrgUrl))" -Type Header

    # WhatIfPreference is set by -WhatIf and by $WhatIfPreference in the caller's scope.
    # Reading it directly, rather than only relying on ShouldProcess, is what lets -Force skip
    # the prompt without also skipping the preview.
    $isWhatIf = $WhatIfPreference

    # -Force has to suppress the per-object ShouldProcess prompts as well as the single
    # ShouldContinue below, or ConfirmImpact = High makes it prompt for every user anyway and
    # the switch does not mean what it says. Setting the preference rather than short-
    # circuiting ShouldProcess is the part that keeps -WhatIf working: ShouldProcess is still
    # called, and still returns false under -WhatIf.
    if ($Force -and -not $isWhatIf) {
        $ConfirmPreference = 'None'
    }

    if (-not $Force -and -not $isWhatIf) {
        $prompt = ("This permanently deletes every user, group and group rule tagged " +
            "'$($connection.Prefix)' in $($connection.OrgUrl). Okta has no undo.")
        if (-not $PSCmdlet.ShouldContinue($prompt, 'Remove Okta test environment')) {
            Write-TestMessage -Message 'Teardown cancelled.' -Type Warning
            if ($PassThru) { return $results }
            return
        }
    }

    # --- 0. Outbound integrations, policies and zones -----------------------------------
    # First, because they reference the groups and zones that later steps delete, and Okta
    # refuses to delete a zone a policy rule still points at. Each is a simple prefix match on
    # a name this module owns, and each deactivates before deleting where the API requires it.
    # Singular is carried explicitly rather than derived by trimming an "s", which turned
    # "policies" into "Delete Okta policie" in the confirmation prompt.
    $simpleSweeps = @(
        @{ Key = 'EventHooks'; Label = 'event hooks'; One = 'event hook'
           Path = '/api/v1/eventHooks'; Deactivate = $true }
        @{ Key = 'TrustedOrigins'; Label = 'trusted origins'; One = 'trusted origin'
           Path = '/api/v1/trustedOrigins'; Deactivate = $true }
        @{ Key = 'Policies'; Label = 'policies'; One = 'policy'
           Path = '/api/v1/policies'; Deactivate = $false }
        @{ Key = 'NetworkZones'; Label = 'network zones'; One = 'network zone'
           Path = '/api/v1/zones'; Deactivate = $true }
    )

    foreach ($sweep in $simpleSweeps) {
        if ($sweep.Key -in $Keep) { continue }

        Write-TestMessage -Message "Removing $($sweep.Label)" -Type Info
        try {
            $query = @{ limit = 200 }
            # The policy list endpoint requires a type; there is no "all policies" listing.
            $items = if ($sweep.Key -eq 'Policies') {
                @(foreach ($policyType in @('OKTA_SIGN_ON', 'PASSWORD')) {
                    Invoke-OktaRequest -Method GET -Path $sweep.Path `
                        -Query @{ type = $policyType; limit = 200 } -Paginate
                })
            }
            else {
                @(Invoke-OktaRequest -Method GET -Path $sweep.Path -Query $query -Paginate)
            }

            $owned = @($items | Where-Object {
                $_.name -and $_.name.StartsWith("$($connection.Prefix)-", [StringComparison]::OrdinalIgnoreCase)
            })

            foreach ($item in $owned) {
                $what = 'Delete Okta {0}' -f $sweep.One
                if (-not $PSCmdlet.ShouldProcess($item.name, $what)) { continue }

                try {
                    if ($sweep.Deactivate) {
                        try {
                            $null = Invoke-OktaRequest -Method POST `
                                -Path "$($sweep.Path)/$($item.id)/lifecycle/deactivate"
                        }
                        catch {
                            Write-Verbose "$($item.name) was already inactive."
                        }
                    }
                    $null = Invoke-OktaRequest -Method DELETE -Path "$($sweep.Path)/$($item.id)"
                    $results.($sweep.Key).Removed += $item.name
                }
                catch {
                    $results.($sweep.Key).Errors += "$($item.name): $($_.Exception.Message)"
                    Write-Error "Failed to delete '$($item.name)': $($_.Exception.Message)"
                }
            }
        }
        catch {
            $results.($sweep.Key).Errors += $_.Exception.Message
            Write-Error "Could not enumerate $($sweep.Label): $($_.Exception.Message)"
        }
    }

    # --- 1. Group rules -----------------------------------------------------------------
    if ('GroupRules' -notin $Keep) {
        Write-TestMessage -Message 'Step 1: Removing group rules' -Type Info
        try {
            $rulePrefix = '{0}-Rule-' -f $connection.Prefix
            $rules = @(Invoke-OktaRequest -Method GET -Path '/api/v1/groups/rules' `
                -Query @{ limit = 200 } -Paginate |
                Where-Object {
                    $_.name -and $_.name.StartsWith($rulePrefix, [StringComparison]::OrdinalIgnoreCase)
                })

            foreach ($rule in $rules) {
                if (-not $PSCmdlet.ShouldProcess($rule.name, 'Delete group rule')) { continue }
                try {
                    try {
                        $null = Invoke-OktaRequest -Method POST `
                            -Path "/api/v1/groups/rules/$($rule.id)/lifecycle/deactivate"
                    }
                    catch {
                        Write-Verbose "Rule $($rule.name) was already inactive."
                    }
                    $null = Invoke-OktaRequest -Method DELETE -Path "/api/v1/groups/rules/$($rule.id)"
                    $results.GroupRules.Removed += $rule.name
                }
                catch {
                    $results.GroupRules.Errors += "$($rule.name): $($_.Exception.Message)"
                    Write-Error "Failed to delete rule '$($rule.name)': $($_.Exception.Message)"
                }
            }
        }
        catch {
            $results.GroupRules.Errors += $_.Exception.Message
            Write-Error "Could not enumerate group rules: $($_.Exception.Message)"
        }
    }

    # --- 2. Users -----------------------------------------------------------------------
    if ('Users' -notin $Keep) {
        Write-TestMessage -Message 'Step 2: Removing users' -Type Info
        try {
            $users = @(Get-OktaSeededUser -Prefix $connection.Prefix -EmailDomain $connection.EmailDomain)

            foreach ($user in $users) {
                $login = $user.profile.login
                if (-not $PSCmdlet.ShouldProcess($login, 'Deactivate and delete Okta user')) { continue }

                try {
                    # Okta deletes in two steps and a DELETE against an active user only
                    # performs the first of them. Deactivating explicitly means the DELETE
                    # below is always the second step, whatever state the user was in.
                    if ($user.status -ne 'DEPROVISIONED') {
                        $null = Invoke-OktaRequest -Method POST `
                            -Path "/api/v1/users/$($user.id)/lifecycle/deactivate"
                    }
                    $null = Invoke-OktaRequest -Method DELETE -Path "/api/v1/users/$($user.id)"
                    $results.Users.Removed += $login
                }
                catch {
                    $results.Users.Errors += "$login`: $($_.Exception.Message)"
                    Write-Error "Failed to delete user '$login': $($_.Exception.Message)"
                }
            }
        }
        catch {
            $results.Users.Errors += $_.Exception.Message
            Write-Error "Could not enumerate seeded users: $($_.Exception.Message)"
        }
    }

    # --- 3. Groups ----------------------------------------------------------------------
    if ('Groups' -notin $Keep) {
        Write-TestMessage -Message 'Step 3: Removing groups' -Type Info
        try {
            $groups = @(Get-OktaSeededGroup -Prefix $connection.Prefix -SeedMarker $connection.SeedMarker)

            foreach ($group in $groups) {
                $name = $group.profile.name
                if (-not $PSCmdlet.ShouldProcess($name, 'Delete Okta group')) { continue }

                try {
                    $null = Invoke-OktaRequest -Method DELETE -Path "/api/v1/groups/$($group.id)"
                    $results.Groups.Removed += $name
                }
                catch {
                    $results.Groups.Errors += "$name`: $($_.Exception.Message)"
                    Write-Error "Failed to delete group '$name': $($_.Exception.Message)"
                }
            }
        }
        catch {
            $results.Groups.Errors += $_.Exception.Message
            Write-Error "Could not enumerate seeded groups: $($_.Exception.Message)"
        }
    }

    # --- 4a. Lab apps -------------------------------------------------------------------
    # Separate from the service app on purpose. -Keep ServiceApp is for keeping the credential
    # you authenticate with; it should not also strand eight lab apps you asked to be rid of.
    if ('Apps' -notin $Keep) {
        Write-TestMessage -Message 'Step 4: Removing app integrations' -Type Info
        try {
            $labApps = @(Get-OktaSeededApp -Prefix $connection.Prefix -EmailDomain $connection.EmailDomain)

            foreach ($app in $labApps) {
                if (-not $PSCmdlet.ShouldProcess($app.label, 'Deactivate and delete Okta app')) { continue }

                try {
                    try {
                        $null = Invoke-OktaRequest -Method POST `
                            -Path "/api/v1/apps/$($app.id)/lifecycle/deactivate"
                    }
                    catch {
                        Write-Verbose "App $($app.label) was already inactive."
                    }
                    $null = Invoke-OktaRequest -Method DELETE -Path "/api/v1/apps/$($app.id)"
                    $results.Apps.Removed += $app.label
                }
                catch {
                    $results.Apps.Errors += "$($app.label): $($_.Exception.Message)"
                    Write-Error "Failed to delete app '$($app.label)': $($_.Exception.Message)"
                }
            }
        }
        catch {
            $results.Apps.Errors += $_.Exception.Message
            Write-Error "Could not enumerate app integrations: $($_.Exception.Message)"
        }
    }

    # --- 4b. Service app ----------------------------------------------------------------
    if ('ServiceApp' -notin $Keep) {
        Write-TestMessage -Message 'Step 5: Removing the service app' -Type Info
        try {
            $serviceApps = @(Get-OktaSeededApp -Prefix $connection.Prefix `
                -EmailDomain $connection.EmailDomain -IncludeServiceApp |
                Where-Object {
                    -not $_.label.StartsWith("$($connection.Prefix)-", [StringComparison]::OrdinalIgnoreCase)
                })

            foreach ($app in $serviceApps) {
                if (-not $PSCmdlet.ShouldProcess($app.label, 'Deactivate and delete Okta app')) { continue }

                try {
                    try {
                        $null = Invoke-OktaRequest -Method POST `
                            -Path "/api/v1/apps/$($app.id)/lifecycle/deactivate"
                    }
                    catch {
                        Write-Verbose "App $($app.label) was already inactive."
                    }
                    $null = Invoke-OktaRequest -Method DELETE -Path "/api/v1/apps/$($app.id)"
                    $results.ServiceApp.Removed += $app.label
                }
                catch {
                    $results.ServiceApp.Errors += "$($app.label): $($_.Exception.Message)"
                    Write-Error "Failed to delete app '$($app.label)': $($_.Exception.Message)"
                }
            }

            if ($RemoveCredentialFile) {
                $credentialPath = Get-OktaCredentialPath -OrgUrl $connection.OrgUrl

                if (Test-Path -Path $credentialPath) {
                    # Read the pointer before deleting it. If the key lives in a vault, the file
                    # is the only record of which vault and which secret, so deleting the file
                    # first would orphan the secret with no way left to find it.
                    $vaultName = $null
                    $secretName = $null
                    try {
                        $stored = Import-OktaAppCredential -Path $credentialPath
                        $vaultName = $stored.vaultName
                        $secretName = $stored.secretName
                    }
                    catch {
                        Write-Verbose ("Could not read '$credentialPath' before deleting it: " +
                            $_.Exception.Message)
                    }

                    if ($vaultName -and $secretName) {
                        try {
                            if (Remove-TestVaultSecret -VaultName $vaultName -SecretName $secretName) {
                                $results.ServiceApp.Removed += "$vaultName\$secretName"
                            }
                        }
                        catch {
                            $results.ServiceApp.Errors += "vault secret: $($_.Exception.Message)"
                            Write-Error "Failed to remove the vault secret: $($_.Exception.Message)"
                        }
                    }

                    $deleteAction = 'Delete the service app credential file'
                    if ($PSCmdlet.ShouldProcess($credentialPath, $deleteAction)) {
                        Remove-Item -Path $credentialPath -Force
                        $results.ServiceApp.Removed += $credentialPath
                    }
                }
            }
        }
        catch {
            $results.ServiceApp.Errors += $_.Exception.Message
            Write-Error "Could not enumerate apps: $($_.Exception.Message)"
        }
    }

    # --- 4c. Linked objects -------------------------------------------------------------
    # After the users and before the schema. Removing a definition removes every link made with
    # it, so by this point the links have already gone with the users that carried them.
    if ('LinkedObjects' -notin $Keep) {
        Write-TestMessage -Message 'Removing linked object definitions' -Type Info
        try {
            $linkRows = @(Import-Csv -Path (Join-Path (Get-OktaDataPath) 'OktaLinkedObjects.csv') `
                -Encoding UTF8)
            $defined = @(Invoke-OktaRequest -Method GET -Path '/api/v1/meta/schemas/user/linkedObjects')

            foreach ($linkRow in $linkRows) {
                if (-not @($defined | Where-Object { $_.primary.name -eq $linkRow.PrimaryName })) { continue }
                if (-not $PSCmdlet.ShouldProcess($linkRow.PrimaryName, 'Delete linked object definition')) {
                    continue
                }

                try {
                    $null = Invoke-OktaRequest -Method DELETE `
                        -Path "/api/v1/meta/schemas/user/linkedObjects/$($linkRow.PrimaryName)"
                    $results.LinkedObjects.Removed += $linkRow.PrimaryName
                }
                catch {
                    $results.LinkedObjects.Errors += "$($linkRow.PrimaryName): $($_.Exception.Message)"
                    Write-Error "Failed to delete linked object '$($linkRow.PrimaryName)': $($_.Exception.Message)"
                }
            }
        }
        catch {
            $results.LinkedObjects.Errors += $_.Exception.Message
            Write-Error "Could not enumerate linked objects: $($_.Exception.Message)"
        }
    }

    # --- 5. Schema ----------------------------------------------------------------------
    # Last on purpose. Removing labSeedTag before the users are gone would leave the next
    # teardown attempt with nothing but the email domain to identify them by.
    if ('Schema' -notin $Keep) {
        Write-TestMessage -Message 'Step 6: Removing custom profile attributes' -Type Info
        try {
            $schemaResult = New-OktaProfileAttribute -Remove -PassThru -Confirm:$false
            $results.Schema.Removed = @($schemaResult.Removed)
            $results.Schema.Errors = @($schemaResult.Errors)
        }
        catch {
            $results.Schema.Errors += $_.Exception.Message
            Write-Error "Could not remove custom profile attributes: $($_.Exception.Message)"
        }
    }

    # --- 6. User types ------------------------------------------------------------------
    # Genuinely last. A type cannot be deleted while a user is on it, and Okta also returns a
    # transient 409 for a short while after its schema is touched - so this both runs after the
    # users are gone and tolerates one retry.
    if ('UserTypes' -notin $Keep) {
        Write-TestMessage -Message 'Removing the second user type' -Type Info
        try {
            $typeRows = @(Import-Csv -Path (Join-Path (Get-OktaDataPath) 'OktaUserTypes.csv') -Encoding UTF8)
            $liveTypes = @(Invoke-OktaRequest -Method GET -Path '/api/v1/meta/types/user')

            foreach ($typeRow in $typeRows) {
                $typeName = Get-OktaUserTypeName -Prefix $connection.Prefix -UserTypeKey $typeRow.Name
                $match = @($liveTypes | Where-Object { $_.name -eq $typeName -and -not $_.default })
                if ($match.Count -eq 0) { continue }
                if (-not $PSCmdlet.ShouldProcess($typeName, 'Delete Okta user type')) { continue }

                $deleted = $false
                foreach ($attempt in 1..2) {
                    try {
                        $null = Invoke-OktaRequest -Method DELETE `
                            -Path "/api/v1/meta/types/user/$($match[0].id)"
                        $deleted = $true
                        break
                    }
                    catch {
                        if ($attempt -eq 2) { throw }
                        Write-Verbose "User type delete returned a conflict; retrying once."
                        Start-Sleep -Seconds 3
                    }
                }

                if ($deleted) { $results.UserTypes.Removed += $typeName }
            }
        }
        catch {
            $results.UserTypes.Errors += $_.Exception.Message
            Write-Error "Could not remove the user type: $($_.Exception.Message)"
        }
    }

    $results.EndTime = Get-Date

    $tracked = @('GroupRules', 'Users', 'Groups', 'Apps', 'ServiceApp', 'Schema',
        'EventHooks', 'TrustedOrigins', 'Policies', 'NetworkZones', 'LinkedObjects', 'UserTypes')
    $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 }
}