Providers/PingOne/Public/Remove-PingOneEnvironment.ps1

function Remove-PingOneEnvironment {
    <#
    .SYNOPSIS
        Removes every object this module created in a PingOne environment, proving ownership first

    .DESCRIPTION
        Reached through Remove-TestEnvironment once a PingOne connection is active.

        Nothing is deleted for matching a name. Every object is selected by
        Get-PingOneSeededObject, which requires the seed tag in the description AND the prefix
        on the name for the types that have a description, asks the seeded populations for
        their users before falling back to the tag, and refuses PingOne's own applications and
        resources by type however they are described.

        The order is the reverse of the seed, and each position is forced by what PingOne will
        and will not delete:

        1. Applications - their grants go with them, and they reference groups and resources.
        2. Resources - their scopes go with them.
        3. Users - their group memberships go with them.
        4. Groups - no longer referenced by any application.
        5. Populations - only empty populations can be deleted, so the users go first.
        6. Attributes - last, because PingOne refuses to delete a custom attribute while
                            any user still holds a value in it.

        The confirmation is asked once, for the whole run, in the body of the function, and not in
        a begin{} block: a return inside begin{} ends that block and nothing else, so a refusal
        there would not stop the deletion. A refusal here stops the function. A session that cannot answer the prompt is refused
        too, so an automated teardown has to pass -Force.

        -WhatIf always wins over -Force. -Force lowers the confirmation preference and nothing
        else; every deletion still goes through ShouldProcess, so "-Force -WhatIf" deletes
        nothing and prints a line for every object it would have removed.

    .PARAMETER Keep
        Object types to leave in place.

    .PARAMETER Force
        Remove without asking for confirmation. Required for any unattended teardown.

    .PARAMETER PassThru
        Return the results object.

    .OUTPUTS
        PSCustomObject describing what was removed, when -PassThru is used.

    .EXAMPLE
        PS> Remove-TestEnvironment -WhatIf

        DESCRIPTION: Lists everything teardown would remove, removing nothing
        OUTPUT: A What if: line per object
        USE CASE: Checking ownership before deleting anything in an environment you care about

    .EXAMPLE
        PS> Remove-TestEnvironment -Force -Keep Attributes -PassThru

        DESCRIPTION: Removes everything except the custom attributes, unattended
        OUTPUT: The results object
        USE CASE: Tearing down between runs while keeping the schema, so the next seed is faster

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

    .LINK
        New-PingOneEnvironment
        Get-PingOneEnvironmentReport
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'The teardown summary is written for the person watching the run; the result object carries the same data for scripts.')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateSet('Applications', 'Resources', 'Users', 'Groups', 'Populations', 'Attributes')]
        [string[]]$Keep = @(),

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$PassThru
    )

    $correlationId = [Guid]::NewGuid()
    Write-Verbose "Starting Remove-PingOneEnvironment - CorrelationId: $correlationId"

    $connection = Get-PingOneConnection

    $removed = [ordered]@{
        Applications = 0
        Resources    = 0
        Users        = 0
        Groups       = 0
        Populations  = 0
        Attributes   = 0
    }
    $errors = [System.Collections.Generic.List[string]]::new()

    $buildResult = {
        param([bool]$Cancelled)
        [PSCustomObject]@{
            CorrelationId   = $correlationId
            EnvironmentId   = $connection.EnvironmentId
            EnvironmentName = $connection.EnvironmentName
            Cancelled       = $Cancelled
            Removed         = [PSCustomObject]$removed
            TotalRemoved    = ($removed.Values | Measure-Object -Sum).Sum
            Errors          = $errors.ToArray()
        }
    }

    # One confirmation for the run, asked here in the body where a refusal actually stops the
    # function. Skipped under -WhatIf, because a preview changes nothing and demanding an answer
    # before showing what would happen made -WhatIf unusable from anything non-interactive.
    if (-not $WhatIfPreference) {
        $question = ("Remove every object this module created in PingOne environment '{0}' ({1})?" -f
            $connection.EnvironmentName, $connection.EnvironmentId)

        # A session with no host to answer is a refusal, not an agreement: an unattended
        # teardown has to say -Force. Confirm-TestTeardown is where that rule lives.
        $confirmed = $Force
        if (-not $confirmed) {
            $confirmed = Confirm-TestTeardown -Cmdlet $PSCmdlet -Question $question -Caption 'Remove PingOne test environment'
        }

        if (-not $confirmed) {
            Write-Host 'Teardown cancelled. Nothing was removed. Pass -Force to remove without being asked.'
            if ($PassThru) { return (& $buildResult $true) }
            return
        }

        # Asked once; every deletion below still passes through ShouldProcess, so -WhatIf is
        # never defeated by this.
        $ConfirmPreference = 'None'
    }

    Write-TestMessage -Message ("PingOne Test Environment Teardown ({0}, {1})" -f
        $connection.EnvironmentName, $connection.EnvironmentId) -Type Header

    $delete = {
        param([string]$Type, [string]$Path, [string]$Label)
        if (-not $PSCmdlet.ShouldProcess($Label, "Delete PingOne $Type")) { return }
        try {
            $null = Invoke-PingOneRequest -Method DELETE -Path $Path -Connection $connection -IgnoreError 'NOT_FOUND'
            $removed[$Type]++
        }
        catch {
            $errors.Add("Could not delete $Type '${Label}': $($_.Exception.Message)")
            Write-Warning "Could not delete $Type '${Label}': $($_.Exception.Message)"
        }
    }

    # --- 1. Applications -------------------------------------------------------------------
    if ($Keep -notcontains 'Applications') {
        Write-Host 'Removing applications'
        foreach ($application in (Get-PingOneSeededObject -Type Applications -Connection $connection)) {
            & $delete 'Applications' "applications/$($application.id)" $application.name
        }
    }

    # --- 2. Resources ----------------------------------------------------------------------
    if ($Keep -notcontains 'Resources') {
        Write-Host 'Removing resources'
        foreach ($resource in (Get-PingOneSeededObject -Type Resources -Connection $connection)) {
            & $delete 'Resources' "resources/$($resource.id)" $resource.name
        }
    }

    # --- 3. Users --------------------------------------------------------------------------
    if ($Keep -notcontains 'Users') {
        Write-Host 'Removing users'
        foreach ($user in (Get-PingOneSeededObject -Type Users -Connection $connection)) {
            & $delete 'Users' "users/$($user.id)" $user.username
        }
    }

    # --- 4. Groups -------------------------------------------------------------------------
    if ($Keep -notcontains 'Groups') {
        Write-Host 'Removing groups'
        foreach ($group in (Get-PingOneSeededObject -Type Groups -Connection $connection)) {
            & $delete 'Groups' "groups/$($group.id)" $group.name
        }
    }

    # --- 5. Populations --------------------------------------------------------------------
    # Only an empty population can be deleted. Keeping users while removing populations is
    # therefore refused up front rather than failing object by object.
    if ($Keep -notcontains 'Populations') {
        if ($Keep -contains 'Users') {
            $errors.Add('Populations were not removed: -Keep Users leaves them holding people, and PingOne only deletes an empty population.')
        }
        else {
            Write-Host 'Removing populations'
            foreach ($population in (Get-PingOneSeededObject -Type Populations -Connection $connection)) {
                & $delete 'Populations' "populations/$($population.id)" $population.name
            }
        }
    }

    # --- 6. Attributes ---------------------------------------------------------------------
    # Last. PingOne refuses to delete a custom attribute while any user holds a value in it.
    if ($Keep -notcontains 'Attributes') {
        if ($Keep -contains 'Users') {
            $errors.Add('Custom attributes were not removed: -Keep Users leaves people holding values in them.')
        }
        else {
            Write-Host 'Removing custom attributes'
            foreach ($attribute in (Get-PingOneSeededObject -Type Attributes -Connection $connection)) {
                & $delete 'Attributes' "schemas/$($attribute.SchemaId)/attributes/$($attribute.id)" $attribute.name
            }
        }
    }

    $result = & $buildResult $false

    Write-TestMessage -Message 'Teardown Summary' -Type Header
    Write-Host ("Objects removed: {0}" -f $result.TotalRemoved)
    foreach ($type in $removed.Keys) { Write-Host (" {0,-13} {1}" -f $type, $removed[$type]) }
    foreach ($message in $errors) { Write-Host (" ! {0}" -f $message) }

    if ($PassThru) { return $result }
}