Providers/Entra/Public/Get-EntraEnvironmentReport.ps1

function Get-EntraEnvironmentReport {
    <#
    .SYNOPSIS
        Reports what is currently seeded in the tenant, and how it is shaped

    .DESCRIPTION
        Reads the seeded environment back out of the tenant and reports it. It reads what is
        actually there rather than what the seed data says should be there, which is the only
        way the report can tell you that a step failed, that somebody deleted a group by
        hand, or that a dynamic group has not evaluated yet.

        Group membership is reported both directly and transitively, because the difference
        is the whole point of the nesting chain. A group that reports four direct members and
        ten transitive ones is working; if those numbers match, either the nesting failed or
        whatever produced the report only expanded one level.

        Licence assignment paths are resolved rather than counted. Graph reports an inherited
        licence and a directly-assigned one identically in assignedLicenses, and only
        licenseAssignmentStates distinguishes them - the inherited entry names the group in
        assignedByGroup. The report resolves that group id back to a name, because a report
        that hands you a bare GUID has made you do the interesting half of the work.

    .PARAMETER OutputFormat
        Console, JSON, HTML or CSV. The file formats are written by the one writer every
        provider shares; CSV is a folder with one file per section. -Format is kept as an alias.

    .PARAMETER OutputPath
        The file to write, or for CSV the folder. Required for anything but Console. -Path is
        kept as an alias.

    .PARAMETER PassThru
        Returns the report object as well.

    .OUTPUTS
        EntraEnvironmentReport, the shape every provider returns, when -PassThru is supplied.

    .EXAMPLE
        PS> Get-EntraEnvironmentReport

        DESCRIPTION: Prints a summary of everything currently seeded
        OUTPUT: A per-type breakdown with membership and licence detail
        USE CASE: Confirming a seed worked, or seeing what is left after a partial teardown

    .EXAMPLE
        PS> Get-EntraEnvironmentReport -OutputFormat JSON -OutputPath .\lab.json

        DESCRIPTION: Writes the full report as JSON
        OUTPUT: The file, and nothing on the pipeline
        USE CASE: Diffing the environment between runs

    .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('EntraEnvironmentReport')]
    param(
        [Parameter()]
        [Alias('Format')]
        [ValidateSet('Console', 'JSON', 'HTML', 'CSV')]
        [string]$OutputFormat = 'Console',

        [Parameter()]
        [Alias('Path')]
        [ValidateNotNullOrEmpty()]
        [string]$OutputPath,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-EntraConnection
    if ($OutputFormat -ne 'Console' -and -not $OutputPath) {
        throw "-OutputPath is required for the $OutputFormat format."
    }
    $marker = Get-EntraSeedMarker -Connection $connection

    Write-Verbose "Reading the seeded environment under '$($marker.Prefix)'"

    $users = @(Get-EntraSeededObject -Type Users -Connection $connection)
    $groups = @(Get-EntraSeededObject -Type Groups -Connection $connection)
    $devices = @(Get-EntraSeededObject -Type Devices -Connection $connection)
    $applications = @(Get-EntraSeededObject -Type Applications -Connection $connection)
    $principals = @(Get-EntraSeededObject -Type ServicePrincipals -Connection $connection)
    $locations = @(Get-EntraSeededObject -Type NamedLocations -Connection $connection)
    $policies = @(Get-EntraSeededObject -Type ConditionalAccessPolicies -Connection $connection)
    # Discovery raises a terminating error here rather than returning empty when it cannot tell,
    # because teardown has to distinguish the two. A report has no such stake, so it degrades to
    # a null count - which prints as blank rather than as a zero that would read as "none".
    $eligibilities = @()
    $eligibilityCount = $null
    $capability = $connection.Capabilities
    if ($capability -and $capability.Known -and -not $capability.EntraP2) {
        # Without P2 there can be none, and the read would be refused: zero, not unknown.
        $eligibilityCount = 0
    }
    else {
        try {
            $eligibilities = @(Get-EntraSeededObject -Type RoleEligibilities -Connection $connection -ErrorAction Stop)
            $eligibilityCount = $eligibilities.Count
        }
        catch {
            Write-Warning "Could not read the role eligibilities, so they are reported as unknown: $($_.Exception.Message)"
        }
    }

    # Counted three different ways on purpose, because the three disagree and the disagreement
    # is the finding. userType misses the guest that was converted to a member; the #EXT# marker
    # misses the one created locally with userType Guest; externalUserState sees only the
    # invitations nobody has redeemed. Any single number here is wrong.
    $externalByType = @($users | Where-Object { $_.userType -eq 'Guest' })
    $externalByUpn = @($users | Where-Object { $_.userPrincipalName -like '*#EXT#@*' })
    $externalPending = @($users | Where-Object { $_.externalUserState -eq 'PendingAcceptance' })

    # Measured, not assumed. The whole point of the eligibility layer is that roleAssignments
    # returns nothing for these roles while three principals can activate into them, and a
    # report that printed a hardcoded zero next to that claim would be asserting it rather than
    # showing it. If this is ever non-zero, somebody made a standing assignment by hand.
    $activeAssignments = @()
    $seededRoleIds = @(@(Get-EntraSeededObject -Type DirectoryRoles -Connection $connection).id)
    foreach ($roleId in $seededRoleIds) {
        try {
            $activeAssignments += @(Invoke-EntraRequest -Method GET -Connection $connection -Paginate `
                    -Path '/roleManagement/directory/roleAssignments' `
                    -Query @{ '$filter' = "roleDefinitionId eq '$roleId'"; '$select' = 'id,principalId,directoryScopeId' })
        }
        catch {
            Write-Verbose "Could not read active assignments for role $roleId : $($_.Exception.Message)"
        }
    }

    # Resolved once so licence paths can name the group rather than hand back a GUID.
    $groupNameById = @{}
    foreach ($group in $groups) { $groupNameById[$group.id] = $group.displayName }

    $skuNameById = @{}
    try {
        foreach ($sku in (Invoke-EntraRequest -Method GET -Path '/subscribedSkus' -Connection $connection).value) {
            $skuNameById[$sku.skuId] = $sku.skuPartNumber
        }
    }
    catch {
        Write-Warning "Could not read the tenant's SKUs, so licences will be reported by id: $($_.Exception.Message)"
    }

    # Managers and licence states are fetched in batches rather than two calls per user. At AD
    # parity that is the difference between a report that takes three minutes and one that
    # takes twenty seconds, and it is the same six hundred requests either way.
    Write-Verbose "Reading managers and licence states for $($users.Count) user(s)"

    $managerByUserId = @{}
    if ($users.Count -gt 0) {
        $managerRequests = foreach ($user in $users) {
            [PSCustomObject]@{ Reference = $user.id; Method = 'GET'; Url = "/users/$($user.id)/manager?`$select=displayName" }
        }
        foreach ($result in (Invoke-EntraBatch -Request @($managerRequests) -Connection $connection -Activity 'Reading managers')) {
            # A user with no manager answers 404 here. Three of the core users are like that
            # on purpose, so it is a value rather than a failure.
            if ($result.Success -and $result.Body) { $managerByUserId[$result.Reference] = $result.Body.displayName }
        }
    }

    $licenseStatesByUserId = @{}
    if ($users.Count -gt 0) {
        $licenceRequests = foreach ($user in $users) {
            [PSCustomObject]@{ Reference = $user.id; Method = 'GET'; Url = "/users/$($user.id)?`$select=licenseAssignmentStates" }
        }
        foreach ($result in (Invoke-EntraBatch -Request @($licenceRequests) -Connection $connection -Activity 'Reading licence states')) {
            if ($result.Success -and $result.Body) { $licenseStatesByUserId[$result.Reference] = @($result.Body.licenseAssignmentStates) }
        }
    }

    $userDetail = foreach ($user in $users) {
        $manager = $managerByUserId[$user.id]

        # Resolved rather than counted. Graph reports an inherited licence and a directly
        # assigned one identically in assignedLicenses; only licenseAssignmentStates
        # distinguishes them, and only by naming the group id in assignedByGroup.
        $licenseDetail = foreach ($state in @($licenseStatesByUserId[$user.id])) {
            if (-not $state) { continue }
            $skuName = if ($skuNameById.ContainsKey($state.skuId)) { $skuNameById[$state.skuId] } else { $state.skuId }
            $path = if ($state.assignedByGroup) {
                $viaName = if ($groupNameById.ContainsKey($state.assignedByGroup)) { $groupNameById[$state.assignedByGroup] } else { $state.assignedByGroup }
                "Inherited from $viaName"
            }
            else { 'Direct' }
            '{0} ({1})' -f $skuName, $path
        }

        [PSCustomObject]@{
            PSTypeName        = 'EntraReportUser'
            DisplayName       = $user.displayName
            UserPrincipalName = $user.userPrincipalName
            Enabled           = $user.accountEnabled
            Department        = $user.department
            UsageLocation     = $user.usageLocation
            Manager           = $manager
            Licenses          = @($licenseDetail)

            # All three, never one derived from the others. A caller that wants to know whether
            # this identity is external has to decide which question it is actually asking.
            UserType          = $user.userType
            ExternalUserState = $user.externalUserState
            IsExternalUpn     = [bool]($user.userPrincipalName -like '*#EXT#@*')
            SeedProof         = $user.SeedProof
        }
    }

    # Counted with $count rather than by paging every member. The difference between direct
    # and transitive membership is the whole point of the nesting, and at parity some groups
    # expand to nearly a hundred members - paging them all back just to measure the length is
    # a lot of traffic for a number Graph will return on its own.
    Write-Verbose "Counting membership for $($groups.Count) group(s)"

    $countMembership = {
        param($Relationship)
        $counts = @{}
        if ($groups.Count -eq 0) { return $counts }

        $requests = foreach ($group in $groups) {
            [PSCustomObject]@{
                Reference = $group.id
                Method    = 'GET'
                Url       = "/groups/$($group.id)/$Relationship/`$count"
                # Graph refuses a $count segment without this, and the header has to sit on
                # the inner request: the outer batch call's headers do not reach it.
                Headers   = @{ 'ConsistencyLevel' = 'eventual' }
            }
        }
        foreach ($result in (Invoke-EntraBatch -Request @($requests) -Connection $connection -Activity "Counting $Relationship")) {
            if ($result.Success) { $counts[$result.Reference] = [int]$result.Body }
        }
        return $counts
    }

    $directCounts = & $countMembership 'members'
    $transitiveCounts = & $countMembership 'transitiveMembers'

    $groupDetail = foreach ($group in $groups) {
        $direct = if ($directCounts.ContainsKey($group.id)) { $directCounts[$group.id] } else { 0 }
        $transitive = if ($transitiveCounts.ContainsKey($group.id)) { $transitiveCounts[$group.id] } else { 0 }

        [PSCustomObject]@{
            PSTypeName        = 'EntraReportGroup'
            DisplayName       = $group.displayName
            Kind              = if (@($group.groupTypes) -contains 'Unified') { 'Unified' } else { 'Security' }
            Membership        = if (@($group.groupTypes) -contains 'DynamicMembership') { 'Dynamic' } else { 'Assigned' }
            MembershipRule    = $group.membershipRule
            DirectMembers     = $direct
            TransitiveMembers = $transitive
            RoleAssignable    = [bool]$group.isAssignableToRole
            Licenses          = @($group.assignedLicenses | ForEach-Object {
                    if ($skuNameById.ContainsKey($_.skuId)) { $skuNameById[$_.skuId] } else { $_.skuId }
                })
            SeedProof         = $group.SeedProof
        }
    }

    $applicationDetail = foreach ($application in $applications) {
        $principal = $principals | Where-Object { $_.appId -eq $application.appId } | Select-Object -First 1
        $assignments = @()
        if ($principal) {
            try {
                $assignments = @((Invoke-EntraRequest -Method GET -Path "/servicePrincipals/$($principal.id)/appRoleAssignedTo" `
                            -Connection $connection -Paginate) | ForEach-Object { $_.principalDisplayName })
            }
            catch {
                Write-Warning "Could not read assignments for '$($application.displayName)': $($_.Exception.Message)"
            }
        }

        [PSCustomObject]@{
            PSTypeName          = 'EntraReportApplication'
            DisplayName         = $application.displayName
            AppId               = $application.appId
            HasServicePrincipal = [bool]$principal
            Hidden              = (@($application.tags) -contains 'HideApp')
            Assignments         = $assignments
            AssignmentCount     = $assignments.Count
            SeedProof           = $application.SeedProof
        }
    }

    $sections = [ordered]@{
        Users             = @($userDetail)
        Groups            = @($groupDetail)
        Applications      = @($applicationDetail)
        Devices           = @($devices | ForEach-Object {
                [PSCustomObject]@{
                    PSTypeName      = 'EntraReportDevice'
                    DisplayName     = $_.displayName
                    OperatingSystem = $_.operatingSystem
                    IsCompliant     = $_.isCompliant
                    IsManaged       = $_.isManaged
                    Enabled         = $_.accountEnabled
                    TrustType       = $_.trustType
                }
            })
        NamedLocations    = @($locations | ForEach-Object {
                [PSCustomObject]@{
                    PSTypeName  = 'EntraReportNamedLocation'
                    DisplayName = $_.displayName
                    Type        = ($_.'@odata.type' -replace '#microsoft.graph.', '')
                }
            })
        Policies          = @($policies | ForEach-Object {
                [PSCustomObject]@{
                    PSTypeName    = 'EntraReportPolicy'
                    DisplayName   = $_.displayName
                    State         = $_.state
                    GrantControls = @($_.grantControls.builtInControls)
                    Operator      = $_.grantControls.operator
                }
            })
        RoleEligibilities = @($eligibilities | ForEach-Object {
                [PSCustomObject]@{
                    PSTypeName  = 'EntraReportRoleEligibility'
                    Role        = $_.displayName
                    PrincipalId = $_.principalId
                    Scope       = $_.directoryScopeId

                    # Spelled out rather than left for the reader to infer from the scope string.
                    # A directory-wide eligibility and one confined to an administrative unit
                    # look nearly identical in the portal and are not remotely the same grant.
                    ScopeKind   = if ($_.directoryScopeId -eq '/') { 'Directory' } else { 'AdministrativeUnit' }
                    Status      = $_.status
                }
            })
    }
    $report = New-TestEnvironmentReport -Provider 'Entra' -Target $connection.TenantId -TypeName 'EntraEnvironmentReport' -Section $sections `
        -Property ([ordered]@{
            TenantId                = $connection.TenantId
            TenantName              = $connection.TenantName
            Prefix                  = $marker.Prefix
            UpnSuffix               = $marker.UpnSuffix
            ServicePrincipals       = $principals.Count
            GuestsByUserType        = $externalByType.Count
            GuestsByExternalUpn     = $externalByUpn.Count
            GuestsPendingAcceptance = $externalPending.Count
            # $null when the tenant refused the read, which is "unknown" and not zero.
            RoleEligibilityCount    = $eligibilityCount
            RoleAssignments         = $activeAssignments.Count
        })

    if ($OutputFormat -eq 'Console') {
        Write-TestMessage -Message "Entra test environment in $($report.TenantName) ($($report.TenantId))" -Type Header
        Write-Host "Prefix $($report.Prefix) on $($report.UpnSuffix)"
        Write-Host ''
        foreach ($property in $report.Counts.PSObject.Properties) {
            Write-Host (' {0,-26} {1}' -f $property.Name, $property.Value)
        }
        foreach ($name in 'ServicePrincipals', 'GuestsByUserType', 'GuestsByExternalUpn', 'GuestsPendingAcceptance', 'RoleAssignments') {
            Write-Host (' {0,-26} {1}' -f $name, $report.$name)
        }
        $eligible = if ($null -eq $report.RoleEligibilityCount) { 'unknown' } else { $report.RoleEligibilityCount }
        Write-Host (' {0,-26} {1}' -f 'RoleEligibilities', $eligible)
        Write-Host ''
        Write-Host 'Users'
        foreach ($u in $report.Users) {
            Write-Host (' {0,-40} enabled={1,-5} manager={2}' -f $u.DisplayName, $u.Enabled, $(if ($u.Manager) { $u.Manager } else { '(none)' }))
            foreach ($licence in $u.Licenses) { Write-Host " licence: $licence" }
        }
        Write-Host ''
        Write-Host 'Groups'
        foreach ($g in $report.Groups) {
            Write-Host (' {0,-40} {1,-9} direct={2,-3} transitive={3,-3}{4}' -f
                $g.DisplayName, $g.Membership, $g.DirectMembers, $g.TransitiveMembers,
                $(if ($g.Licenses) { " licences=$($g.Licenses -join ',')" } else { '' }))
        }
        Write-Host ''
        Write-Host 'Applications'
        foreach ($a in $report.Applications) {
            Write-Host (' {0,-40} sp={1,-5} assignments={2}' -f $a.DisplayName, $a.HasServicePrincipal, $a.AssignmentCount)
        }
        Write-Host ''
        Write-Host 'Conditional Access policies'
        foreach ($p in $report.Policies) {
            Write-Host (' {0,-46} {1}' -f $p.DisplayName, $p.State)
        }
    }
    else {
        Export-TestEnvironmentReport -Report $report -OutputFormat $OutputFormat -OutputPath $OutputPath `
            -FilePrefix 'EntraLab' -Title 'Entra Test Environment Report' `
            -Note @("Tenant $($report.TenantName) ($($report.TenantId))", "Prefix $($report.Prefix) on $($report.UpnSuffix)")
    }

    if ($PassThru) { return $report }
}