Providers/PingOne/Private/Get-PingOneSeededObject.ps1

function Get-PingOneSeededObject {
    <#
    .SYNOPSIS
        Returns the objects of one type that this module can prove it created

    .DESCRIPTION
        Teardown's only source of truth. Nothing is deleted for merely matching a name, and this
        is where that promise is kept.

        Proof differs by type, because PingOne gives each type a different place to hold it:

        - Populations, groups, applications and resources carry the seed tag in their
          description, and their name must also carry the prefix. Both are required: the tag
          alone could be pasted into a real object's description by accident, and the prefix
          alone is exactly the name-matching this module refuses to do.
        - Users are proved by the population first. A user in a seeded population is ours.
          The custom attribute is the fallback, for a user moved out of a seeded population by
          hand, and on its own it is enough, because nothing but this module writes that
          attribute - the module creates it.
        - The custom attributes themselves are proved by name, and only the names this
          provider's data file declares. There is no description to check, and a custom
          attribute this module did not declare is left alone however it is named.

        PingOne's own applications and resources are refused outright by type, whatever their
        description says, so a platform application that somehow picked up the tag can never
        be selected for deletion.

        The seed marker contains no wildcard characters, but it is tested with Contains rather
        than -like anyway, so a future marker with brackets in it cannot silently match nothing.

    .PARAMETER Type
        Which kind of object to return.

    .PARAMETER Connection
        The connection to use. Defaults to the session's.

    .OUTPUTS
        The PingOne objects this module owns, of the requested type.

    .EXAMPLE
        PS> Get-PingOneSeededObject -Type Users

        DESCRIPTION: Lists the seeded people, by population and then by tag
        OUTPUT: User objects
        USE CASE: Called by Remove-PingOneEnvironment and Get-PingOneEnvironmentReport

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


    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet('Populations', 'Users', 'Groups', 'Applications', 'Resources', 'Attributes')]
        [string]$Type,

        [Parameter()]
        [hashtable]$Connection
    )

    if (-not $Connection) { $Connection = Get-PingOneConnection }

    $marker = Get-PingOneSeedMarker -Prefix $Connection.Prefix
    $tag = $marker.Tag
    $prefix = $marker.Prefix

    # Tag in the description AND the prefix on the name. Either alone is not proof.
    $isTagged = {
        param($object)
        $description = [string]$object.description
        $name = [string]$object.name
        return ($description.Contains($tag) -and
            $name.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase))
    }

    switch ($Type) {
        'Populations' {
            return @(Invoke-PingOneRequest -Method GET -Path 'populations' -Paginate -Connection $Connection |
                    Where-Object { & $isTagged $_ })
        }

        'Groups' {
            return @(Invoke-PingOneRequest -Method GET -Path 'groups' -Paginate -Connection $Connection |
                    Where-Object { & $isTagged $_ })
        }

        'Resources' {
            return @(Invoke-PingOneRequest -Method GET -Path 'resources' -Paginate -Connection $Connection |
                    Where-Object { $script:PingOnePlatformResourceType -notcontains $_.type } |
                    Where-Object { & $isTagged $_ })
        }

        'Applications' {
            return @(Invoke-PingOneRequest -Method GET -Path 'applications' -Paginate -Connection $Connection |
                    Where-Object { $script:PingOnePlatformApplicationType -notcontains $_.type } |
                    Where-Object { & $isTagged $_ })
        }

        'Users' {
            $seededPopulationIds = @{}
            foreach ($population in (Get-PingOneSeededObject -Type Populations -Connection $Connection)) {
                $seededPopulationIds[[string]$population.id] = $true
            }

            $claimed = @{}
            $result = [System.Collections.Generic.List[object]]::new()

            # By population first. Filtering server-side keeps a large environment from being
            # read whole just to find the seeded few hundred.
            foreach ($populationId in $seededPopulationIds.Keys) {
                $filter = 'population.id eq "{0}"' -f $populationId
                foreach ($user in (Invoke-PingOneRequest -Method GET -Path 'users' -Paginate -Connection $Connection -Query @{ filter = $filter })) {
                    if (-not $claimed.ContainsKey([string]$user.id)) {
                        $claimed[[string]$user.id] = $true
                        $result.Add($user)
                    }
                }
            }

            # Then by the tag, for a seeded user moved out of a seeded population by hand.
            #
            # Only when the attribute exists. PingOne refuses a filter naming an attribute that is
            # not in the schema, with HTTP 400 REQUEST_FAILED - a code broad enough that ignoring
            # it would also hide a genuine failure. The attribute is absent on a fresh environment
            # and again after teardown removes it last, so a report before the first seed, or a
            # teardown run twice, used to throw here. Asking the schema first is deterministic and
            # needs no guess about which error code means "no such attribute".
            $attributeName = $script:PingOneSeedAttributeName
            $attributeExists = $false
            foreach ($schema in (Invoke-PingOneRequest -Method GET -Path 'schemas' -Paginate -Connection $Connection)) {
                $match = @(Invoke-PingOneRequest -Method GET -Path "schemas/$($schema.id)/attributes" -Paginate -Connection $Connection |
                        Where-Object { $_.name -eq $attributeName })
                if ($match) { $attributeExists = $true; break }
            }

            $tagged = @()
            if ($attributeExists) {
                $filter = '{0} eq "{1}"' -f $attributeName, $tag
                # Nulls filtered out explicitly, so no path can ever hand teardown a user with no
                # id - which would become "DELETE users/" with nothing after the slash.
                $tagged = @(Invoke-PingOneRequest -Method GET -Path 'users' -Paginate -Connection $Connection `
                        -Query @{ filter = $filter } | Where-Object { $null -ne $_ -and $_.id })
            }
            foreach ($user in $tagged) {
                if (-not $claimed.ContainsKey([string]$user.id)) {
                    $claimed[[string]$user.id] = $true
                    $result.Add($user)
                }
            }

            return $result.ToArray()
        }

        'Attributes' {
            $declared = @(Import-Csv -LiteralPath (Join-Path (Get-PingOneDataPath) 'PingOneProfileAttributes.csv') -Encoding UTF8).Name

            $schemas = @(Invoke-PingOneRequest -Method GET -Path 'schemas' -Paginate -Connection $Connection)
            $result = [System.Collections.Generic.List[object]]::new()
            foreach ($schema in $schemas) {
                foreach ($attribute in (Invoke-PingOneRequest -Method GET -Path "schemas/$($schema.id)/attributes" -Paginate -Connection $Connection)) {
                    # CUSTOM only. A CORE or STANDARD attribute is the platform's, whatever it
                    # is called, and the name check below would otherwise be the only guard.
                    if ($attribute.schemaType -ne 'CUSTOM') { continue }
                    if ($declared -notcontains [string]$attribute.name) { continue }
                    $attribute | Add-Member -NotePropertyName SchemaId -NotePropertyValue $schema.id -Force
                    $result.Add($attribute)
                }
            }
            return $result.ToArray()
        }
    }
}