Providers/FreeIPA/Public/Get-FreeIPAEnvironmentReport.ps1

function Get-FreeIPAEnvironmentReport {
    <#
    .SYNOPSIS
        Reads back what the seed created and renders it to the console or a file

    .DESCRIPTION
        Lists the seeded users in every lifecycle state with their memberships, class,
        authentication type and expiries, the groups with their type, GID, member counts and
        parents, the host groups with their member counts and parents, the hosts with their
        operating system, class, host groups, manager and whether anything has ever enrolled,
        the netgroups with their members, the HBAC and sudo rules with who, where and what
        they grant and whether they are on, the roles with their privileges and holders, the
        password policies by priority, the services with their indicators and the delegation
        rules and targets between them, the ID views with the hosts they apply to and every
        override inside them, the tokens with their state and expiry, the automember rules
        with their conditions, the automount keys, the SELinux maps, the certificate
        mapping rules, the CA ACLs with who they cover, the certificates the realm's CA
        issued with their status and expiry, the seed's DNS zones with every record in
        them, and the RADIUS proxies and identity providers with the users linked to each.
        Only objects the module can prove it owns are included, so the report
        is a picture of the seed and not of the realm.

        Console output is for a person; JSON, CSV and HTML are for a file, and each writes
        UTF-8 explicitly, because the seeded names carry accents on purpose and the default
        encoding on Windows PowerShell would destroy them.

    .PARAMETER OutputFormat
        Console, JSON, HTML or CSV.

    .PARAMETER OutputPath
        The file to write, or for CSV the folder. Required for anything but Console.

    .PARAMETER PassThru
        Returns the report object as well.

    .OUTPUTS
        PSCustomObject. The report, when -PassThru is supplied.

    .EXAMPLE
        PS> Get-FreeIPAEnvironmentReport

        DESCRIPTION: Prints the seeded estate to the console
        OUTPUT: One table per object type
        USE CASE: A quick look after seeding

    .EXAMPLE
        PS> Get-FreeIPAEnvironmentReport -OutputFormat JSON -OutputPath ./freeipa-report.json

        DESCRIPTION: Writes the report as JSON
        OUTPUT: The file, and nothing on the pipeline
        USE CASE: Diffing the estate before and after a change

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


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'The console format exists to be read by a person at the console.')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateSet('Console', 'JSON', 'HTML', 'CSV')]
        [string]$OutputFormat = 'Console',

        [Parameter()]
        [string]$OutputPath,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-FreeIPAConnection
    $marker = Get-FreeIPASeedMarker -Connection $connection

    if ($OutputFormat -ne 'Console' -and [string]::IsNullOrWhiteSpace($OutputPath)) {
        throw "-OutputPath is required for the $OutputFormat format."
    }

    $first = { param($value) if ($value -is [array]) { if ($value.Count -gt 0) { [string]$value[0] } else { '' } } else { if ($null -eq $value) { '' } else { [string]$value } } }
    $list = { param($value) if ($null -eq $value) { @() } else { @($value | ForEach-Object { [string]$_ }) } }
    $has = { param($entry, $name) $entry.PSObject.Properties[$name] -and $null -ne $entry.$name }
    $get = { param($entry, $name) if (& $has $entry $name) { $entry.$name } else { $null } }
    $prefixed = { param($names) @($names | Where-Object { ([string]$_).StartsWith($marker.NamePrefix, [StringComparison]::OrdinalIgnoreCase) }) }
    $withoutMarker = { param($text) ([string]$text).Replace($marker.Marker, '').Trim() }
    $classOf = { param($entry) (@(& $list (& $get $entry 'userclass') | Where-Object { $_ -ne $marker.Tag }) -join '; ') }
    $whenUtc = { param($value) $offset = ConvertFrom-FreeIPADateTime -Value $value; if ($offset) { $offset.UtcDateTime } else { $null } }
    # A link to a proxy or provider comes back as the target's DN; its name is the first RDN.
    $linkName = { param($value) $text = [string]$value; if ($text -match '^cn=([^,]+)') { $Matches[1] } else { $text } }

    $active = @(Get-FreeIPASeededObject -Type Users -IncludeServiceAccount -Detail -Connection $connection)
    $preserved = @(Get-FreeIPASeededObject -Type PreservedUsers -Detail -Connection $connection)
    $staged = @(Get-FreeIPASeededObject -Type StagedUsers -Detail -Connection $connection)
    $groups = @(Get-FreeIPASeededObject -Type Groups -Detail -Connection $connection)
    $hostgroups = @(Get-FreeIPASeededObject -Type Hostgroups -Detail -Connection $connection)
    $hosts = @(Get-FreeIPASeededObject -Type Hosts -Detail -Connection $connection)
    $netgroups = @(Get-FreeIPASeededObject -Type Netgroups -Detail -Connection $connection)
    $hbacRules = @(Get-FreeIPASeededObject -Type HbacRules -Detail -Connection $connection)
    $sudoRules = @(Get-FreeIPASeededObject -Type SudoRules -Detail -Connection $connection)
    $roles = @(Get-FreeIPASeededObject -Type Roles -Detail -Connection $connection)
    $policies = @(Get-FreeIPASeededObject -Type PasswordPolicies -Detail -Connection $connection)
    $services = @(Get-FreeIPASeededObject -Type Services -Detail -Connection $connection)
    $delegationRules = @(Get-FreeIPASeededObject -Type ServiceDelegationRules -Detail -Connection $connection)
    $delegationTargets = @(Get-FreeIPASeededObject -Type ServiceDelegationTargets -Detail -Connection $connection)
    $views = @(Get-FreeIPASeededObject -Type IdViews -Detail -Connection $connection)
    $tokens = @(Get-FreeIPASeededObject -Type OtpTokens -Detail -Connection $connection)
    $automemberRules = @(Get-FreeIPASeededObject -Type AutomemberRules -Detail -Connection $connection)
    $locations = @(Get-FreeIPASeededObject -Type AutomountLocations -Connection $connection)
    $selinuxMaps = @(Get-FreeIPASeededObject -Type SelinuxUserMaps -Detail -Connection $connection)
    $certMapRules = @(Get-FreeIPASeededObject -Type CertMapRules -Detail -Connection $connection)
    $caAcls = @(Get-FreeIPASeededObject -Type CaAcls -Detail -Connection $connection)
    $certificates = @(Get-FreeIPASeededObject -Type Certificates -Connection $connection)
    $radiusProxies = @(Get-FreeIPASeededObject -Type RadiusProxies -Detail -Connection $connection)
    $identityProviders = @(Get-FreeIPASeededObject -Type IdentityProviders -Detail -Connection $connection)
    $dnsZones = @(Get-FreeIPASeededObject -Type DnsZones -Detail -Connection $connection)
    $dnsRecords = @(Get-FreeIPASeededObject -Type DnsRecords -Connection $connection)
    # Every record type FreeIPA stores is an attribute ending in 'record'; one row per value.
    $recordRows = foreach ($record in $dnsRecords) {
        $name = ConvertFrom-FreeIPADnsName -Value $record.idnsname
        foreach ($property in @($record.PSObject.Properties | Where-Object { $_.Name -like '*record' -and $_.Name -ne 'idnsrecord' })) {
            foreach ($value in @($property.Value)) {
                [PSCustomObject]@{ Zone = $record.zonename; Name = $name; Type = $property.Name.Substring(0, $property.Name.Length - 6).ToUpperInvariant(); Data = (ConvertFrom-FreeIPADnsName -Value $value) }
            }
        }
    }

    $joined = { param($entry, $name) ((& $list (& $get $entry $name)) -join '; ') }
    $first0 = { param($value) if ($value -is [array]) { if ($value.Count -gt 0) { [string]$value[0] } else { '' } } else { if ($null -eq $value) { '' } else { [string]$value } } }

    # A view's hosts and overrides, and a location's maps and keys, are read per object;
    # the listing does not carry them.
    $viewDetail = @{}
    foreach ($view in $views) {
        $viewName = & $first0 $view.cn
        $shown = Invoke-FreeIPARequest -Method 'idview_show' -Arguments $viewName -Options @{ show_hosts = $true } -Connection $connection -IgnoreError 'NotFound'
        $viewDetail[$viewName] = @{
            Hosts  = @(if ($shown -and $shown.result) { & $list (& $get $shown.result 'appliedtohosts') })
            Users  = @(Invoke-FreeIPARequest -Method 'idoverrideuser_find' -Arguments $viewName -Options @{ all = $true } -Find -Connection $connection)
            Groups = @(Invoke-FreeIPARequest -Method 'idoverridegroup_find' -Arguments $viewName -Options @{ all = $true } -Find -Connection $connection)
        }
    }
    $automountKeys = foreach ($location in $locations) {
        $locationName = & $first0 $location.cn
        foreach ($map in @(Invoke-FreeIPARequest -Method 'automountmap_find' -Arguments $locationName -Find -Connection $connection)) {
            $mapName = & $first0 $map.automountmapname
            foreach ($key in @(Invoke-FreeIPARequest -Method 'automountkey_find' -Arguments @($locationName, $mapName) -Find -Connection $connection)) {
                [PSCustomObject]@{
                    Location = $locationName
                    Map      = $mapName
                    Key      = (& $first0 $key.automountkey)
                    Info     = (& $first0 (& $get $key 'automountinformation'))
                }
            }
        }
    }
    # A who/where/what clause is either a category of all or the members it names.
    $clause = { param($entry, $category, $names) if ((& $first (& $get $entry $category)) -eq 'all') { 'all' } else { (@($names | ForEach-Object { & $list (& $get $entry $_) }) -join '; ') } }
    $isOn = { param($entry) $flag = & $get $entry 'ipaenabledflag'; if ($null -eq $flag) { $true } else { [bool](@($flag)[0]) } }

    $userRow = {
        param($entry, $lifecycle)
        [PSCustomObject]@{
            Login             = (& $first $entry.uid)
            Name              = (& $first (& $get $entry 'cn'))
            Lifecycle         = $lifecycle
            Class             = (& $classOf $entry)
            Title             = (& $first (& $get $entry 'title'))
            OrgUnit           = (& $first (& $get $entry 'ou'))
            Manager           = (& $first (& $get $entry 'manager'))
            Groups            = ((& $prefixed (& $list (& $get $entry 'memberof_group'))) -join '; ')
            AuthType          = ((& $list (& $get $entry 'ipauserauthtype')) -join '; ')
            AuthLink          = ((@(& $list (& $get $entry 'ipatokenradiusconfiglink')) + @(& $list (& $get $entry 'ipaidpconfiglink')) | ForEach-Object { & $linkName $_ }) -join '; ')
            PasswordExpires   = (& $whenUtc (& $get $entry 'krbpasswordexpiration'))
            PrincipalExpires  = (& $whenUtc (& $get $entry 'krbprincipalexpiration'))
            PublicKeys        = @(& $list (& $get $entry 'ipasshpubkey')).Count
        }
    }

    $groupType = {
        param($entry)
        $classes = @(& $list (& $get $entry 'objectclass'))
        if ($classes -contains 'ipaexternalgroup') { 'external' }
        elseif ($classes -contains 'posixgroup') { 'posix' }
        else { 'nonposix' }
    }

    $report = [PSCustomObject]@{
        GeneratedUtc = [DateTime]::UtcNow.ToString('o')
        BaseUrl      = $connection.BaseUrl
        Prefix       = $connection.Prefix
        AuthType     = $connection.AuthType
        Users        = @(
            @($active | ForEach-Object { & $userRow $_ $(if ((& $get $_ 'nsaccountlock') -eq $true) { 'Disabled' } else { 'Active' }) }) +
            @($preserved | ForEach-Object { & $userRow $_ 'Preserved' }) +
            @($staged | ForEach-Object { & $userRow $_ 'Staged' }) | Sort-Object Login
        )
        Groups       = @($groups | ForEach-Object {
                [PSCustomObject]@{
                    Name         = (& $first $_.cn)
                    Type         = (& $groupType $_)
                    Gid          = (& $first (& $get $_ 'gidnumber'))
                    Description  = (& $withoutMarker (& $first (& $get $_ 'description')))
                    MemberUsers  = @(& $list (& $get $_ 'member_user')).Count
                    MemberGroups = ((& $list (& $get $_ 'member_group')) -join '; ')
                    MemberOf     = ((& $prefixed (& $list (& $get $_ 'memberof_group'))) -join '; ')
                    Managers     = ((@(& $list (& $get $_ 'membermanager_user')) + @(& $list (& $get $_ 'membermanager_group'))) -join '; ')
                }
            } | Sort-Object Name)
        Hostgroups   = @($hostgroups | ForEach-Object {
                [PSCustomObject]@{
                    Name         = (& $first $_.cn)
                    Description  = (& $withoutMarker (& $first (& $get $_ 'description')))
                    MemberHosts  = @(& $list (& $get $_ 'member_host')).Count
                    MemberGroups = ((& $list (& $get $_ 'member_hostgroup')) -join '; ')
                    MemberOf     = ((& $prefixed (& $list (& $get $_ 'memberof_hostgroup'))) -join '; ')
                    Managers     = ((@(& $list (& $get $_ 'membermanager_user')) + @(& $list (& $get $_ 'membermanager_group'))) -join '; ')
                }
            } | Sort-Object Name)
        Hosts        = @($hosts | ForEach-Object {
                $fqdn = (& $first $_.fqdn)
                [PSCustomObject]@{
                    Name            = $fqdn
                    Description     = (& $withoutMarker (& $first (& $get $_ 'description')))
                    OperatingSystem = (& $first (& $get $_ 'nsosversion'))
                    Platform        = (& $first (& $get $_ 'nshardwareplatform'))
                    Locality        = (& $first (& $get $_ 'l'))
                    Class           = (& $classOf $_)
                    Hostgroups      = ((& $prefixed (& $list (& $get $_ 'memberof_hostgroup'))) -join '; ')
                    ManagedBy       = (@(& $list (& $get $_ 'managedby_host') | Where-Object { $_ -ne $fqdn }) -join '; ')
                    Enrolled        = ((& $get $_ 'has_keytab') -eq $true)
                }
            } | Sort-Object Name)
        Netgroups         = @($netgroups | ForEach-Object {
                [PSCustomObject]@{
                    Name        = (& $first $_.cn)
                    Description = (& $withoutMarker (& $first (& $get $_ 'description')))
                    Users       = (& $clause $_ 'usercategory' @('memberuser_user'))
                    Groups      = (& $joined $_ 'memberuser_group')
                    Hosts       = (& $clause $_ 'hostcategory' @('memberhost_host'))
                    Hostgroups  = (& $joined $_ 'memberhost_hostgroup')
                    Netgroups   = (& $joined $_ 'member_netgroup')
                }
            } | Sort-Object Name)
        HbacRules         = @($hbacRules | ForEach-Object {
                [PSCustomObject]@{
                    Name          = (& $first $_.cn)
                    Enabled       = (& $isOn $_)
                    Users         = (& $clause $_ 'usercategory' @('memberuser_user', 'memberuser_group'))
                    Hosts         = (& $clause $_ 'hostcategory' @('memberhost_host', 'memberhost_hostgroup'))
                    Services      = (& $clause $_ 'servicecategory' @('memberservice_hbacsvc', 'memberservice_hbacsvcgroup'))
                    Description   = (& $withoutMarker (& $first (& $get $_ 'description')))
                }
            } | Sort-Object Name)
        SudoRules         = @($sudoRules | ForEach-Object {
                [PSCustomObject]@{
                    Name          = (& $first $_.cn)
                    Enabled       = (& $isOn $_)
                    Order         = (& $first (& $get $_ 'sudoorder'))
                    Users         = (& $clause $_ 'usercategory' @('memberuser_user', 'memberuser_group'))
                    Hosts         = (& $clause $_ 'hostcategory' @('memberhost_host', 'memberhost_hostgroup'))
                    AllowCommands = (& $clause $_ 'cmdcategory' @('memberallowcmd_sudocmd', 'memberallowcmd_sudocmdgroup'))
                    DenyCommands  = (& $joined $_ 'memberdenycmd_sudocmd')
                    RunAsUsers    = (& $clause $_ 'ipasudorunasusercategory' @('ipasudorunas_user', 'ipasudorunasextuser'))
                    Options       = (& $joined $_ 'ipasudoopt')
                }
            } | Sort-Object Name)
        Roles             = @($roles | ForEach-Object {
                [PSCustomObject]@{
                    Name        = (& $first $_.cn)
                    Description = (& $withoutMarker (& $first (& $get $_ 'description')))
                    Privileges  = (& $joined $_ 'memberof_privilege')
                    Users       = (& $joined $_ 'member_user')
                    Groups      = (& $joined $_ 'member_group')
                    Hosts       = (& $joined $_ 'member_host')
                }
            } | Sort-Object Name)
        PasswordPolicies  = @($policies | ForEach-Object {
                [PSCustomObject]@{
                    Group       = (& $first $_.cn)
                    Priority    = (& $first (& $get $_ 'cospriority'))
                    MaxLife     = (& $first (& $get $_ 'krbmaxpwdlife'))
                    MinLength   = (& $first (& $get $_ 'krbpwdminlength'))
                    MinClasses  = (& $first (& $get $_ 'krbpwdmindiffchars'))
                    History     = (& $first (& $get $_ 'krbpwdhistorylength'))
                    MaxFail     = (& $first (& $get $_ 'krbpwdmaxfailure'))
                    LockoutTime = (& $first (& $get $_ 'krbpwdlockoutduration'))
                    GraceLimit  = (& $first (& $get $_ 'passwordgracelimit'))
                }
            } | Sort-Object Priority)
        Services          = @($services | ForEach-Object {
                $principal = (& $first $_.krbcanonicalname)
                [PSCustomObject]@{
                    Principal      = $principal
                    Host           = ((($principal -split '@')[0] -split '/', 2)[-1])
                    AuthIndicators = (& $joined $_ 'krbprincipalauthind')
                    ManagedBy      = (@(& $list (& $get $_ 'managedby_host') | Where-Object { $_ -ne ((($principal -split '@')[0] -split '/', 2)[-1]) }) -join '; ')
                    Enrolled       = ((& $get $_ 'has_keytab') -eq $true)
                }
            } | Sort-Object Principal)
        ServiceDelegation = @(
            @($delegationRules | ForEach-Object {
                    [PSCustomObject]@{ Kind = 'Rule'; Name = (& $first $_.cn); Members = (& $joined $_ 'memberprincipal'); Targets = (& $joined $_ 'ipaallowedtarget_servicedelegationtarget') }
                }) +
            @($delegationTargets | ForEach-Object {
                    [PSCustomObject]@{ Kind = 'Target'; Name = (& $first $_.cn); Members = (& $joined $_ 'memberprincipal'); Targets = '' }
                }) | Sort-Object Kind, Name
        )
        IdViews           = @($views | ForEach-Object {
                $viewName = (& $first $_.cn)
                [PSCustomObject]@{
                    Name           = $viewName
                    Description    = (& $withoutMarker (& $first (& $get $_ 'description')))
                    AppliedTo      = (@($viewDetail[$viewName].Hosts) -join '; ')
                    UserOverrides  = @($viewDetail[$viewName].Users).Count
                    GroupOverrides = @($viewDetail[$viewName].Groups).Count
                }
            } | Sort-Object Name)
        IdOverrides       = @(
            @(foreach ($viewName in ($viewDetail.Keys | Sort-Object)) {
                    foreach ($o in $viewDetail[$viewName].Users) {
                        [PSCustomObject]@{ View = $viewName; Kind = 'User'; Anchor = (& $first0 (& $get $o 'ipaoriginaluid')); Login = (& $first0 (& $get $o 'uid')); Uid = (& $first0 (& $get $o 'uidnumber')); Gid = (& $first0 (& $get $o 'gidnumber')); Shell = (& $first0 (& $get $o 'loginshell')); Home = (& $first0 (& $get $o 'homedirectory')) }
                    }
                    foreach ($o in $viewDetail[$viewName].Groups) {
                        [PSCustomObject]@{ View = $viewName; Kind = 'Group'; Anchor = (& $first0 (& $get $o 'ipaanchoruuid')); Login = (& $first0 (& $get $o 'cn')); Uid = ''; Gid = (& $first0 (& $get $o 'gidnumber')); Shell = ''; Home = '' }
                    }
                })
        )
        OtpTokens         = @($tokens | ForEach-Object {
                $notAfter = & $whenUtc (& $get $_ 'ipatokennotafter')
                [PSCustomObject]@{
                    Id       = (& $first $_.ipatokenuniqueid)
                    Owner    = (& $first (& $get $_ 'ipatokenowner'))
                    Type     = (& $first (& $get $_ 'type'))
                    Enabled  = -not ((& $get $_ 'ipatokendisabled') -eq $true)
                    NotAfter = $notAfter
                    Expired  = ($null -ne $notAfter -and $notAfter -lt [DateTime]::UtcNow)
                    Digits   = (& $first (& $get $_ 'ipatokenotpdigits'))
                    Vendor   = (& $first (& $get $_ 'ipatokenvendor'))
                    Model    = (& $first (& $get $_ 'ipatokenmodel'))
                }
            } | Sort-Object Id)
        AutomemberRules   = @($automemberRules | ForEach-Object {
                [PSCustomObject]@{
                    Target      = (& $first $_.cn)
                    Type        = $_.automembertype
                    Inclusive   = (& $joined $_ 'automemberinclusiveregex')
                    Exclusive   = (& $joined $_ 'automemberexclusiveregex')
                    Description = (& $withoutMarker (& $first (& $get $_ 'description')))
                }
            } | Sort-Object Type, Target)
        Automount         = @($automountKeys | Sort-Object Location, Map, Key)
        SelinuxUserMaps   = @($selinuxMaps | ForEach-Object {
                [PSCustomObject]@{
                    Name        = (& $first $_.cn)
                    SelinuxUser = (& $first (& $get $_ 'ipaselinuxuser'))
                    Enabled     = (& $isOn $_)
                    HbacRule    = (& $first (& $get $_ 'seealso'))
                    Users       = (& $clause $_ 'usercategory' @('memberuser_user', 'memberuser_group'))
                    Hosts       = (& $clause $_ 'hostcategory' @('memberhost_host', 'memberhost_hostgroup'))
                }
            } | Sort-Object Name)
        CertMapRules      = @($certMapRules | ForEach-Object {
                [PSCustomObject]@{
                    Name      = (& $first $_.cn)
                    Enabled   = (& $isOn $_)
                    Priority  = (& $first (& $get $_ 'ipacertmappriority'))
                    MatchRule = (& $first (& $get $_ 'ipacertmapmatchrule'))
                    MapRule   = (& $first (& $get $_ 'ipacertmapmaprule'))
                }
            } | Sort-Object Name)
        CaAcls            = @($caAcls | ForEach-Object {
                [PSCustomObject]@{
                    Name     = (& $first $_.cn)
                    Enabled  = (& $isOn $_)
                    Users    = (& $clause $_ 'usercategory' @('memberuser_user', 'memberuser_group'))
                    Hosts    = (& $clause $_ 'hostcategory' @('memberhost_host', 'memberhost_hostgroup'))
                    Services = (& $clause $_ 'servicecategory' @('memberservice_service'))
                    Profiles = (& $clause $_ 'ipacertprofilecategory' @('ipamembercertprofile_certprofile'))
                    CAs      = (& $clause $_ 'ipacacategory' @('ipamemberca_ca'))
                }
            } | Sort-Object Name)
        Certificates      = @($certificates | ForEach-Object {
                $notAfter = ConvertFrom-FreeIPACertificateDate -Value (& $get $_ 'valid_not_after')
                $ownerAttribute = switch ($_.ownerkind) { 'user' { 'owner_user' } 'service' { 'owner_service' } default { 'owner_host' } }
                [PSCustomObject]@{
                    Serial   = (& $first $_.serial_number)
                    Owner    = (& $first (& $get $_ $ownerAttribute))
                    Kind     = $_.ownerkind
                    Subject  = (& $first (& $get $_ 'subject'))
                    Status   = (& $first (& $get $_ 'status'))
                    Reason   = (& $first (& $get $_ 'revocation_reason'))
                    NotAfter = $notAfter
                    Expired  = ($null -ne $notAfter -and $notAfter -lt [DateTime]::UtcNow)
                }
            } | Sort-Object Kind, Owner, Serial)
        DnsZones          = @($dnsZones | ForEach-Object {
                $zoneName = ConvertFrom-FreeIPADnsName -Value $_.idnsname
                [PSCustomObject]@{
                    Name          = $zoneName
                    Kind          = $_.zonekind
                    Active        = ((& $first (& $get $_ 'idnszoneactive')) -ne $false)
                    DynamicUpdate = ((& $first (& $get $_ 'idnsallowdynupdate')) -eq $true)
                    Contact       = (ConvertFrom-FreeIPADnsName -Value (& $get $_ 'idnssoarname'))
                    Records       = @($recordRows | Where-Object { $_.Zone -eq $zoneName }).Count
                }
            } | Sort-Object Kind)
        DnsRecords        = @($recordRows | Sort-Object Zone, Name, Type, Data)
        IdentityProviders = @(
            @($radiusProxies | ForEach-Object {
                    $proxyName = & $first $_.cn
                    [PSCustomObject]@{
                        Name        = $proxyName
                        Kind        = 'Radius'
                        Endpoint    = (& $first (& $get $_ 'ipatokenradiusserver'))
                        ClientId    = ''
                        Scope       = ''
                        LinkedUsers = @($active | Where-Object { (& $linkName (& $first (& $get $_ 'ipatokenradiusconfiglink'))) -eq $proxyName }).Count
                    }
                }) + @($identityProviders | ForEach-Object {
                    $idpName = & $first $_.cn
                    [PSCustomObject]@{
                        Name        = $idpName
                        Kind        = 'Idp'
                        Endpoint    = (& $first (& $get $_ 'ipaidpauthendpoint'))
                        ClientId    = (& $first (& $get $_ 'ipaidpclientid'))
                        Scope       = (& $first (& $get $_ 'ipaidpscope'))
                        LinkedUsers = @($active | Where-Object { (& $linkName (& $first (& $get $_ 'ipaidpconfiglink'))) -eq $idpName }).Count
                    }
                }) | Sort-Object Kind, Name)
    }

    switch ($OutputFormat) {
        'Console' {
            Write-TestMessage -Message "FreeIPA Test Environment Report ($($connection.BaseUrl))" -Type Header
            foreach ($section in $script:FreeIPAReportSections) {
                Write-Host "$section ($(@($report.$section).Count)):" -ForegroundColor Cyan
                if (@($report.$section).Count -gt 0) {
                    $report.$section | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
                }
                else { Write-Host '' }
            }
        }
        'JSON' {
            $json = $report | ConvertTo-Json -Depth 10
            [System.IO.File]::WriteAllBytes($OutputPath, [System.Text.Encoding]::UTF8.GetBytes($json))
            Write-Verbose "Wrote $OutputPath"
        }
        'CSV' {
            if (-not (Test-Path -LiteralPath $OutputPath)) { $null = New-Item -ItemType Directory -Path $OutputPath -Force }
            foreach ($section in $script:FreeIPAReportSections) {
                $file = Join-Path -Path $OutputPath -ChildPath "FreeIPALab$section.csv"
                $report.$section | Export-Csv -Path $file -NoTypeInformation -Encoding UTF8
            }
            Write-Verbose "Wrote $($script:FreeIPAReportSections.Count) CSV files to $OutputPath"
        }
        'HTML' {
            $style = @'
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 2em; color: #222; }
h1 { font-size: 1.4em; } h2 { font-size: 1.1em; margin-top: 1.5em; }
table { border-collapse: collapse; } th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; }
th { background: #f0f0f0; }
</style>
'@

            $fragments = foreach ($section in $script:FreeIPAReportSections) {
                "<h2>$section ($(@($report.$section).Count))</h2>"
                if (@($report.$section).Count -gt 0) { $report.$section | ConvertTo-Html -Fragment }
            }
            $html = @(
                '<!DOCTYPE html><html><head><meta charset="utf-8"><title>FreeIPA Test Environment Report</title>', $style, '</head><body>'
                "<h1>FreeIPA Test Environment Report</h1><p>$($connection.BaseUrl) &middot; prefix $($connection.Prefix) &middot; generated $($report.GeneratedUtc)</p>"
                $fragments
                '</body></html>'
            ) -join "`n"
            [System.IO.File]::WriteAllBytes($OutputPath, [System.Text.Encoding]::UTF8.GetBytes($html))
            Write-Verbose "Wrote $OutputPath"
        }
    }

    if ($PassThru) { return $report }
}