Providers/FreeIPA/Private/Get-FreeIPASeededObject.ps1

function Get-FreeIPASeededObject {
    <#
    .SYNOPSIS
        Finds the objects of one type that this module created, and nothing else

    .DESCRIPTION
        Teardown and the report both need the same answer: which of the objects in the realm
        are ours. FreeIPA has no container to ask, so each type has to satisfy the evidence
        the module wrote when it created the object:

        - Users carry the seed tag in their userclass attribute, which user-find filters on
          server-side. Human logins carry no prefix, so this is the only evidence they have.
          Staged users are a separate container and a separate search; preserved users are
          found by asking for them. The automation service account is a tagged user with a
          reserved login and is excluded unless -IncludeServiceAccount is passed, because it
          is the credential the session is using.
        - Hosts carry the tag in userclass AND the prefix on the fully qualified name.
        - Groups, host groups, netgroups, HBAC services and service groups, HBAC rules, sudo
          command groups, sudo rules, privileges and roles carry the prefix on the name AND
          the bracketed marker in their description. A rule named like ours by an
          administrator, without the marker, is left alone.
        - Sudo commands are named by their path and cannot carry a prefix, so the marker in
          the description is the whole proof, and a command that already existed without it
          is never ours.
        - Permissions, service delegation rules and targets, and automount locations have no
          description, so the prefix on the name is all they can carry.
        - ID views, OTP tokens, automember rules, SELinux user maps and certificate mapping
          rules carry the prefix on their name or identifier AND the marker in their
          description. An automember rule is found once per kind and tagged with the kind,
          because the API keeps group rules and host group rules apart.
        - Password policies are keyed by the group they apply to, so a policy is ours when
          its group is a seeded group.
        - Services carry the prefix on the host part of the principal and belong to a seeded
          host.

    .PARAMETER Type
        Which objects to find.

    .PARAMETER IncludeServiceAccount
        For Users: include the module's own automation account.

    .PARAMETER Detail
        Ask for every attribute rather than the default listing. The report needs it; a
        teardown does not.

    .PARAMETER Connection
        The connection to look through. Defaults to the active one.

    .OUTPUTS
        System.Object[]. The entries as FreeIPA returned them, or an empty array.

    .EXAMPLE
        PS> Get-FreeIPASeededObject -Type Users

        DESCRIPTION: Lists the seeded users
        OUTPUT: Every active or disabled user carrying the tag, minus the automation account
        USE CASE: Membership resolution in the seed steps, and the users step of teardown

    .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('Users', 'StagedUsers', 'PreservedUsers', 'Groups', 'Hosts', 'Hostgroups', 'Netgroups',
            'HbacServices', 'HbacServiceGroups', 'HbacRules', 'SudoCommands', 'SudoCommandGroups', 'SudoRules',
            'Permissions', 'Privileges', 'Roles', 'PasswordPolicies', 'Services', 'ServiceDelegationRules',
            'ServiceDelegationTargets', 'IdViews', 'OtpTokens', 'AutomemberRules', 'AutomountLocations', 'SelinuxUserMaps',
            'CertMapRules', 'CaAcls', 'Certificates', 'DnsZones', 'DnsRecords', 'RadiusProxies', 'IdentityProviders')]
        [string]$Type,

        [Parameter()]
        [switch]$IncludeServiceAccount,

        [Parameter()]
        [switch]$Detail,

        [Parameter()]
        [hashtable]$Connection
    )

    if (-not $Connection) { $Connection = Get-FreeIPAConnection }
    $marker = Get-FreeIPASeedMarker -Connection $Connection
    $prefix = $marker.NamePrefix

    $first = { param($value) if ($value -is [array]) { if ($value.Count -gt 0) { [string]$value[0] } else { '' } } else { [string]$value } }

    $hasTag = {
        param($entry)
        $entry.PSObject.Properties[$marker.Attribute] -and (@($entry.($marker.Attribute)) -contains $marker.Tag)
    }
    # A string method rather than -like: the marker's square brackets are wildcard characters
    # to -like, which would refuse the pattern and find nothing.
    $hasMarker = {
        param($entry)
        $entry.PSObject.Properties['description'] -and ((& $first $entry.description).Contains($marker.Marker))
    }
    $startsWithPrefix = {
        param($name)
        $name -and ([string]$name).StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
    }

    $options = @{}
    if ($Detail) { $options['all'] = $true }

    # The common shape: a find by the prefix as the search string, then the prefix on cn and
    # the marker in the description.
    $prefixedWithMarker = {
        param($method)
        $entries = @(Invoke-FreeIPARequest -Method $method -Arguments $prefix -Options $options -Find -Connection $Connection)
        return @($entries | Where-Object { (& $startsWithPrefix (& $first $_.cn)) -and (& $hasMarker $_) })
    }
    $prefixedOnly = {
        param($method)
        $entries = @(Invoke-FreeIPARequest -Method $method -Arguments $prefix -Options $options -Find -Connection $Connection)
        return @($entries | Where-Object { & $startsWithPrefix (& $first $_.cn) })
    }

    switch ($Type) {
        'Users' {
            $serviceAccount = Get-FreeIPAServiceAccountName -Marker $marker
            $options[$marker.Attribute] = $marker.Tag
            $users = @(Invoke-FreeIPARequest -Method 'user_find' -Options $options -Find -Connection $Connection)
            return @($users | Where-Object {
                    (& $hasTag $_) -and
                    ($IncludeServiceAccount -or (& $first $_.uid) -ne $serviceAccount)
                })
        }
        'StagedUsers' {
            $options[$marker.Attribute] = $marker.Tag
            $users = @(Invoke-FreeIPARequest -Method 'stageuser_find' -Options $options -Find -Connection $Connection)
            return @($users | Where-Object { & $hasTag $_ })
        }
        'PreservedUsers' {
            $options[$marker.Attribute] = $marker.Tag
            $options['preserved'] = $true
            $users = @(Invoke-FreeIPARequest -Method 'user_find' -Options $options -Find -Connection $Connection)
            return @($users | Where-Object { & $hasTag $_ })
        }
        'Groups' { return & $prefixedWithMarker 'group_find' }
        'Hostgroups' { return & $prefixedWithMarker 'hostgroup_find' }
        'Hosts' {
            $options[$marker.Attribute] = $marker.Tag
            $hosts = @(Invoke-FreeIPARequest -Method 'host_find' -Options $options -Find -Connection $Connection)
            return @($hosts | Where-Object { (& $hasTag $_) -and (& $startsWithPrefix (& $first $_.fqdn)) })
        }
        'Netgroups' { return & $prefixedWithMarker 'netgroup_find' }
        'HbacServices' { return & $prefixedWithMarker 'hbacsvc_find' }
        'HbacServiceGroups' { return & $prefixedWithMarker 'hbacsvcgroup_find' }
        'HbacRules' { return & $prefixedWithMarker 'hbacrule_find' }
        'SudoCommands' {
            # Searched by the marker, since the name is a path. The description is the proof.
            $commands = @(Invoke-FreeIPARequest -Method 'sudocmd_find' -Arguments $marker.Marker -Options $options -Find -Connection $Connection)
            return @($commands | Where-Object { & $hasMarker $_ })
        }
        'SudoCommandGroups' { return & $prefixedWithMarker 'sudocmdgroup_find' }
        'SudoRules' { return & $prefixedWithMarker 'sudorule_find' }
        'Permissions' { return & $prefixedOnly 'permission_find' }
        'Privileges' { return & $prefixedWithMarker 'privilege_find' }
        'Roles' { return & $prefixedWithMarker 'role_find' }
        'PasswordPolicies' {
            $groupNames = @((Get-FreeIPASeededObject -Type Groups -Connection $Connection) | ForEach-Object { & $first $_.cn })
            $policies = @(Invoke-FreeIPARequest -Method 'pwpolicy_find' -Arguments $prefix -Options $options -Find -Connection $Connection)
            return @($policies | Where-Object { $groupNames -contains (& $first $_.cn) })
        }
        'Services' {
            $hostNames = @((Get-FreeIPASeededObject -Type Hosts -Connection $Connection) | ForEach-Object { & $first $_.fqdn })
            $services = @(Invoke-FreeIPARequest -Method 'service_find' -Arguments $prefix -Options $options -Find -Connection $Connection)
            return @($services | Where-Object {
                    $principal = & $first $_.krbcanonicalname
                    if (-not $principal -and $_.PSObject.Properties['krbprincipalname']) { $principal = & $first $_.krbprincipalname }
                    $serviceHost = (($principal -split '@')[0] -split '/', 2)[-1]
                    (& $startsWithPrefix $serviceHost) -and ($hostNames -contains $serviceHost)
                })
        }
        'ServiceDelegationRules' { return & $prefixedOnly 'servicedelegationrule_find' }
        'ServiceDelegationTargets' { return & $prefixedOnly 'servicedelegationtarget_find' }
        'IdViews' { return & $prefixedWithMarker 'idview_find' }
        'OtpTokens' {
            $tokens = @(Invoke-FreeIPARequest -Method 'otptoken_find' -Arguments $prefix -Options $options -Find -Connection $Connection)
            return @($tokens | Where-Object { (& $startsWithPrefix (& $first $_.ipatokenuniqueid)) -and (& $hasMarker $_) })
        }
        'AutomemberRules' {
            $found = foreach ($kind in 'group', 'hostgroup') {
                $kindOptions = @{ type = $kind }
                foreach ($key in $options.Keys) { $kindOptions[$key] = $options[$key] }
                @(Invoke-FreeIPARequest -Method 'automember_find' -Arguments $prefix -Options $kindOptions -Find -NoLimit -Connection $Connection) |
                    Where-Object { (& $startsWithPrefix (& $first $_.cn)) -and (& $hasMarker $_) } |
                    ForEach-Object { Add-Member -InputObject $_ -NotePropertyName 'automembertype' -NotePropertyValue $kind -Force -PassThru }
            }
            return @($found)
        }
        'AutomountLocations' { return & $prefixedOnly 'automountlocation_find' }
        'SelinuxUserMaps' { return & $prefixedWithMarker 'selinuxusermap_find' }
        'CertMapRules' { return & $prefixedWithMarker 'certmaprule_find' }
        'CaAcls' { return & $prefixedWithMarker 'caacl_find' }
        'RadiusProxies' { return & $prefixedWithMarker 'radiusproxy_find' }
        # A provider has no description to carry the marker; the prefix is the proof.
        'IdentityProviders' { return & $prefixedOnly 'idp_find' }
        'DnsZones' {
            # A zone is ours by its SOA contact, which only the seed writes, and by being one
            # of the two names the seed derives; a reverse zone's name cannot carry a prefix.
            $zone = Get-FreeIPASeedZone -Marker $marker -Connection $Connection
            $options['idnssoarname'] = $zone.Contact
            $zones = @(Invoke-FreeIPARequest -Method 'dnszone_find' -Options $options -Find -Connection $Connection)
            $ours = @(($zone.Forward.TrimEnd('.') + '.'), $zone.Reverse)
            return @($zones | Where-Object {
                    (ConvertFrom-FreeIPADnsName -Value $_.idnssoarname) -eq $zone.Contact -and $ours -contains (ConvertFrom-FreeIPADnsName -Value $_.idnsname)
                } | ForEach-Object {
                    $kind = if ((ConvertFrom-FreeIPADnsName -Value $_.idnsname) -eq $zone.Reverse) { 'Reverse' } else { 'Forward' }
                    Add-Member -InputObject $_ -NotePropertyName 'zonekind' -NotePropertyValue $kind -Force -PassThru
                })
        }
        'DnsRecords' {
            $found = foreach ($seededZone in @(Get-FreeIPASeededObject -Type DnsZones -Connection $Connection)) {
                $zoneName = ConvertFrom-FreeIPADnsName -Value $seededZone.idnsname
                @(Invoke-FreeIPARequest -Method 'dnsrecord_find' -Arguments $zoneName -Options $options -Find -Connection $Connection) |
                    ForEach-Object { Add-Member -InputObject $_ -NotePropertyName 'zonename' -NotePropertyValue $zoneName -Force -PassThru }
            }
            return @($found)
        }
        'Certificates' {
            # A certificate has no description and a user certificate's subject is the bare
            # login, so the proof is the owner. The CA is asked one owner at a time, because
            # cert_find given two owners returns what both hold, not what either does; and
            # only for the seeded entries that carry a certificate at all, which the full
            # listings show, so three hundred users cost three calls and not three hundred.
            # The full record is always asked for: without it the serial number arrives as
            # a JSON number too large for Windows PowerShell to keep exact.
            $owners = @(
                @{ Option = 'user'; Attribute = 'uid'; Entries = @(Get-FreeIPASeededObject -Type Users -IncludeServiceAccount -Detail -Connection $Connection) }
                @{ Option = 'service'; Attribute = 'krbcanonicalname'; Entries = @(Get-FreeIPASeededObject -Type Services -Detail -Connection $Connection) }
                @{ Option = 'host'; Attribute = 'fqdn'; Entries = @(Get-FreeIPASeededObject -Type Hosts -Detail -Connection $Connection) }
            )
            $seen = @{}
            $found = foreach ($owner in $owners) {
                $holders = @($owner.Entries | Where-Object { $_.PSObject.Properties['usercertificate'] -and @($_.usercertificate).Count -gt 0 } | ForEach-Object { & $first $_.($owner.Attribute) } | Where-Object { $_ })
                foreach ($holder in $holders) {
                    $searchOptions = @{ all = $true }
                    $searchOptions[$owner.Option] = [object[]]@($holder)
                    foreach ($cert in @(Invoke-FreeIPARequest -Method 'cert_find' -Options $searchOptions -Find -Connection $Connection)) {
                        $serial = & $first $cert.serial_number
                        if ($seen.ContainsKey($serial)) { continue }
                        $seen[$serial] = $true
                        Add-Member -InputObject $cert -NotePropertyName 'ownerkind' -NotePropertyValue $owner.Option -Force -PassThru
                    }
                }
            }
            return @($found)
        }
    }
}