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 Format Console, Object, Json, Csv or Html .PARAMETER Path Where to write, for the file formats. Defaults to the current directory. .OUTPUTS EntraEnvironmentReport with -Format Object, otherwise a file path or console text. .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 -Format Json -Path .\lab.json DESCRIPTION: Writes the full report as JSON OUTPUT: The path written 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/ #> [CmdletBinding()] [OutputType('EntraEnvironmentReport')] param( [Parameter()] [ValidateSet('Console', 'Object', 'Json', 'Csv', 'Html')] [string]$Format = 'Console', [Parameter()] [ValidateNotNullOrEmpty()] [string]$Path ) $connection = Get-EntraConnection $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 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 } } $report = [PSCustomObject]@{ PSTypeName = 'EntraEnvironmentReport' TenantId = $connection.TenantId TenantName = $connection.TenantName Prefix = $marker.Prefix UpnSuffix = $marker.UpnSuffix GeneratedAt = Get-Date 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 } }) Counts = [ordered]@{ Users = $users.Count GuestsByUserType = $externalByType.Count GuestsByExternalUpn = $externalByUpn.Count GuestsPendingAcceptance = $externalPending.Count Groups = $groups.Count Devices = $devices.Count Applications = $applications.Count ServicePrincipals = $principals.Count NamedLocations = $locations.Count ConditionalAccessPolicies = $policies.Count RoleEligibilities = $eligibilityCount RoleAssignments = $activeAssignments.Count } } switch ($Format) { 'Object' { return $report } 'Json' { $target = if ($Path) { $Path } else { Join-Path (Get-Location) 'entra-test-environment.json' } $report | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $target -Encoding UTF8 Write-Verbose "Wrote the report to $target" return $target } 'Csv' { # One row per object across every type, because a report spanning seven object # types cannot be one rectangle without inventing columns that mean different # things per row. $target = if ($Path) { $Path } else { Join-Path (Get-Location) 'entra-test-environment.csv' } $rows = @( foreach ($u in $report.Users) { [PSCustomObject]@{ Type = 'User'; Name = $u.DisplayName; Detail = $u.UserPrincipalName; Extra = "manager=$($u.Manager); licences=$($u.Licenses -join ' | ')" } } foreach ($g in $report.Groups) { [PSCustomObject]@{ Type = 'Group'; Name = $g.DisplayName; Detail = "$($g.Kind)/$($g.Membership)"; Extra = "direct=$($g.DirectMembers); transitive=$($g.TransitiveMembers)" } } foreach ($d in $report.Devices) { [PSCustomObject]@{ Type = 'Device'; Name = $d.DisplayName; Detail = $d.OperatingSystem; Extra = "compliant=$($d.IsCompliant); managed=$($d.IsManaged)" } } foreach ($a in $report.Applications) { [PSCustomObject]@{ Type = 'Application'; Name = $a.DisplayName; Detail = $a.AppId; Extra = "assignments=$($a.AssignmentCount)" } } foreach ($l in $report.NamedLocations) { [PSCustomObject]@{ Type = 'NamedLocation'; Name = $l.DisplayName; Detail = $l.Type; Extra = '' } } foreach ($p in $report.Policies) { [PSCustomObject]@{ Type = 'CaPolicy'; Name = $p.DisplayName; Detail = $p.State; Extra = "$($p.Operator): $($p.GrantControls -join ', ')" } } ) $rows | Export-Csv -LiteralPath $target -NoTypeInformation -Encoding UTF8 Write-Verbose "Wrote the report to $target" return $target } 'Html' { $target = if ($Path) { $Path } else { Join-Path (Get-Location) 'entra-test-environment.html' } $sections = foreach ($name in 'Users', 'Groups', 'Devices', 'Applications', 'NamedLocations', 'Policies') { $items = $report.$name if (-not $items) { continue } # ConvertTo-Html -Fragment encodes its input, so seeded names carrying # accented characters or an ampersand survive rather than corrupting the page. "<h2>$name ($(@($items).Count))</h2>" + ($items | ConvertTo-Html -Fragment) } $style = @' <style> body { font-family: Segoe UI, sans-serif; margin: 2rem; color: #1a1a1a; } table { border-collapse: collapse; margin-bottom: 2rem; width: 100%; } th, td { border: 1px solid #d0d0d0; padding: 0.4rem 0.6rem; text-align: left; font-size: 0.9rem; } th { background: #f2f2f2; } h1 { font-size: 1.4rem; } h2 { font-size: 1.1rem; margin-top: 1.5rem; } .meta { color: #555; font-size: 0.9rem; margin-bottom: 1.5rem; } </style> '@ $header = "<h1>Entra test environment</h1><p class='meta'>Tenant $($report.TenantName) " + "($($report.TenantId))<br/>Prefix $($report.Prefix) on $($report.UpnSuffix)<br/>" + "Generated $($report.GeneratedAt)</p>" ConvertTo-Html -Head $style -Body ($header + ($sections -join "`n")) | Set-Content -LiteralPath $target -Encoding UTF8 Write-Verbose "Wrote the report to $target" return $target } default { $lines = [System.Collections.Generic.List[string]]::new() $lines.Add("Entra test environment in $($report.TenantName) ($($report.TenantId))") $lines.Add("Prefix $($report.Prefix) on $($report.UpnSuffix)") $lines.Add('') foreach ($entry in $report.Counts.GetEnumerator()) { $lines.Add((' {0,-26} {1}' -f $entry.Key, $entry.Value)) } $lines.Add('') $lines.Add('Users') foreach ($u in $report.Users) { $lines.Add((' {0,-40} enabled={1,-5} manager={2}' -f $u.DisplayName, $u.Enabled, $(if ($u.Manager) { $u.Manager } else { '(none)' }))) foreach ($licence in $u.Licenses) { $lines.Add(" licence: $licence") } } $lines.Add('') $lines.Add('Groups') foreach ($g in $report.Groups) { $lines.Add((' {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 { '' }))) } $lines.Add('') $lines.Add('Applications') foreach ($a in $report.Applications) { $lines.Add((' {0,-40} sp={1,-5} assignments={2}' -f $a.DisplayName, $a.HasServicePrincipal, $a.AssignmentCount)) } $lines.Add('') $lines.Add('Conditional Access policies') foreach ($p in $report.Policies) { $lines.Add((' {0,-46} {1}' -f $p.DisplayName, $p.State)) } return ($lines -join [Environment]::NewLine) } } } |