Providers/Okta/Public/Get-OktaEnvironmentReport.ps1
|
function Get-OktaEnvironmentReport { <# .SYNOPSIS Reports on what this module has created in the connected tenant .DESCRIPTION Reads back the seeded users, groups, memberships, group rules, custom schema attributes and service app, and renders them. The report is the fastest way to answer the two questions that come up constantly with a licence-capped tenant: how many user slots are left, and did the group rules actually populate anything. Both are shown first for that reason. Memberships are read per group rather than per user, because Okta's group membership endpoint is authoritative and a user's own record does not list its groups. .PARAMETER OutputFormat Console, JSON, HTML or CSV. CSV writes one file per object type, since users, groups and rules have nothing in common to flatten into a single table. .PARAMETER OutputPath Destination file for JSON and HTML, or destination folder for CSV. Required for anything other than Console. .PARAMETER PassThru Return the report object as well as rendering it .OUTPUTS PSCustomObject describing the environment, when -PassThru is used .EXAMPLE Get-OktaEnvironmentReport Prints a summary to the console .EXAMPLE Get-OktaEnvironmentReport -OutputFormat HTML -OutputPath .\okta-lab.html .EXAMPLE Get-OktaEnvironmentReport -OutputFormat CSV -OutputPath .\reports\ Writes OktaLabUsers.csv, OktaLabGroups.csv and OktaLabRules.csv .NOTES Author: Jeffrey Stuhr Version: 1.0.0 Last Updated: 2026-08-07 CSV export is written as UTF-8 explicitly. Windows PowerShell's Export-Csv defaults to ASCII and would replace every accented character in the seeded names with a question mark, which is the exact data loss those names exist to expose. .LINK New-OktaEnvironment #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Console rendering is one of the supported output formats.')] [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter()] [ValidateSet('Console', 'JSON', 'HTML', 'CSV')] [string]$OutputFormat = 'Console', [Parameter()] [string]$OutputPath, [Parameter()] [switch]$PassThru ) $connection = Get-OktaConnection if ($OutputFormat -ne 'Console' -and -not $OutputPath) { throw "-OutputPath is required for the $OutputFormat format." } $headroom = Get-OktaUserHeadroom -ActiveUserLimit $connection.ActiveUserLimit $users = @(Get-OktaSeededUser -Prefix $connection.Prefix -EmailDomain $connection.EmailDomain) $groups = @(Get-OktaSeededGroup -Prefix $connection.Prefix -SeedMarker $connection.SeedMarker) $rulePrefix = '{0}-Rule-' -f $connection.Prefix $rules = @(Invoke-OktaRequest -Method GET -Path '/api/v1/groups/rules' -Query @{ limit = 200 } -Paginate | Where-Object { $_.name -and $_.name.StartsWith($rulePrefix, [StringComparison]::OrdinalIgnoreCase) }) $apps = @(Get-OktaSeededApp -Prefix $connection.Prefix ` -EmailDomain $connection.EmailDomain -IncludeServiceApp) # Every user type, not just the default. Reading only the default schema is the exact # mistake the second user type exists to expose, so the report must not make it. $userTypes = @(Invoke-OktaRequest -Method GET -Path '/api/v1/meta/types/user') $ownedTypes = @($userTypes | Where-Object { $_.name -and $_.name.StartsWith((Get-OktaUserTypeName -Prefix $connection.Prefix -UserTypeKey ''), [StringComparison]::OrdinalIgnoreCase) }) $customAttributes = foreach ($type in $userTypes) { $schemaPath = '/api/v1/meta/schemas/user/default' if (-not $type.default -and $type._links -and $type._links.schema) { $schemaPath = ([uri]$type._links.schema.href).AbsolutePath } $schema = $null try { $schema = Invoke-OktaRequest -Method GET -Path $schemaPath } catch { Write-Verbose "Could not read schema for '$($type.name)': $($_.Exception.Message)" } if ($schema -and $schema.definitions.custom.properties) { $schema.definitions.custom.properties.PSObject.Properties | Where-Object { $_.Name -like 'lab*' } | ForEach-Object { [PSCustomObject]@{ UserType = $type.name Name = $_.Name Title = $_.Value.title Type = $_.Value.type } } } } $zones = @(Invoke-OktaRequest -Method GET -Path '/api/v1/zones' -Query @{ limit = 200 } -Paginate | Where-Object { $_.name -and $_.name.StartsWith("$($connection.Prefix)-", [StringComparison]::OrdinalIgnoreCase) }) # There is no "all policies" listing; the endpoint requires a type. $policies = @(foreach ($policyType in @('OKTA_SIGN_ON', 'PASSWORD')) { Invoke-OktaRequest -Method GET -Path '/api/v1/policies' ` -Query @{ type = $policyType; limit = 200 } -Paginate | Where-Object { $_.name -and $_.name.StartsWith("$($connection.Prefix)-", [StringComparison]::OrdinalIgnoreCase) } }) $origins = @(Invoke-OktaRequest -Method GET -Path '/api/v1/trustedOrigins' ` -Query @{ limit = 200 } -Paginate | Where-Object { $_.name -and $_.name.StartsWith("$($connection.Prefix)-", [StringComparison]::OrdinalIgnoreCase) }) $hooks = @(Invoke-OktaRequest -Method GET -Path '/api/v1/eventHooks' ` -Query @{ limit = 200 } -Paginate | Where-Object { $_.name -and $_.name.StartsWith("$($connection.Prefix)-", [StringComparison]::OrdinalIgnoreCase) }) $linkedObjects = @(Invoke-OktaRequest -Method GET -Path '/api/v1/meta/schemas/user/linkedObjects') $userReport = foreach ($user in $users) { [PSCustomObject]@{ Login = $user.profile.login Name = $user.profile.displayName Status = $user.status Title = $user.profile.title Department = $user.profile.department Manager = $user.profile.manager Clearance = $user.profile.labClearanceLevel Contractor = $user.profile.labIsContractor RiskScore = $user.profile.labRiskScore } } $groupReport = foreach ($group in $groups) { # Wrapped for the same reason the app assignments are: a permission problem or a rate # limit on ONE group should cost you that group's member count, not the whole report. $members = @() try { $members = @(Invoke-OktaRequest -Method GET -Path "/api/v1/groups/$($group.id)/users" ` -Query @{ limit = 200 } -Paginate) } catch { Write-Warning ("Could not read members of '$($group.profile.name)': " + "$($_.Exception.Message). Its count is reported as zero.") } [PSCustomObject]@{ Name = $group.profile.name Description = $group.profile.description MemberCount = $members.Count Members = @($members | ForEach-Object { $_.profile.login }) } } # Assignments are read per app rather than per user, because Okta's app-user endpoint is # authoritative and a user's own record does not list the apps they can reach. The scope # field is what distinguishes a direct assignment from a group-derived one, and keeping # both counts separate is the whole point: an access review that reports only the total # cannot tell you who would lose access if a group were unassigned. $appReport = foreach ($app in $apps) { $assignedGroups = @() $assignedUsers = @() try { $assignedGroups = @(Invoke-OktaRequest -Method GET ` -Path "/api/v1/apps/$($app.id)/groups" -Query @{ limit = 200 } -Paginate) $assignedUsers = @(Invoke-OktaRequest -Method GET ` -Path "/api/v1/apps/$($app.id)/users" -Query @{ limit = 200 } -Paginate) } catch { Write-Verbose "Could not read assignments for '$($app.label)': $($_.Exception.Message)" } [PSCustomObject]@{ Label = $app.label Id = $app.id Status = $app.status SignOnMode = $app.signOnMode GroupCount = $assignedGroups.Count UserCount = $assignedUsers.Count DirectUsers = @($assignedUsers | Where-Object { $_.scope -eq 'USER' } | ForEach-Object { $_.credentials.userName }) } } $ruleReport = foreach ($rule in $rules) { [PSCustomObject]@{ Name = $rule.name Status = $rule.status Expression = $rule.conditions.expression.value TargetIds = @($rule.actions.assignUserToGroups.groupIds) } } $report = [PSCustomObject]@{ GeneratedUtc = [DateTime]::UtcNow.ToString('o') OrgUrl = $connection.OrgUrl Prefix = $connection.Prefix AuthType = $connection.AuthType Licence = [PSCustomObject]@{ ActiveUserLimit = $headroom.Limit InUse = $headroom.InUse Available = $headroom.Available } Users = @($userReport) Groups = @($groupReport) GroupRules = @($ruleReport) CustomAttributes = @($customAttributes) Apps = @($appReport) UserTypes = @($ownedTypes | ForEach-Object { [PSCustomObject]@{ Name = $_.name; DisplayName = $_.displayName; Id = $_.id } }) NetworkZones = @($zones | ForEach-Object { [PSCustomObject]@{ Name = $_.name Usage = $_.usage Status = $_.status Gateways = @($_.gateways | ForEach-Object { $_.value }) } }) Policies = @($policies | ForEach-Object { [PSCustomObject]@{ Name = $_.name Type = $_.type Status = $_.status Priority = $_.priority } }) TrustedOrigins = @($origins | ForEach-Object { [PSCustomObject]@{ Name = $_.name Origin = $_.origin Scopes = @($_.scopes | ForEach-Object { $_.type }) } }) EventHooks = @($hooks | ForEach-Object { [PSCustomObject]@{ Name = $_.name Status = $_.status Uri = $_.channel.config.uri Events = @($_.events.items) } }) LinkedObjects = @($linkedObjects | ForEach-Object { [PSCustomObject]@{ Primary = $_.primary.name; Associated = $_.associated.name } }) } switch ($OutputFormat) { 'Console' { Write-TestMessage -Message "Okta Test Environment - $($report.OrgUrl)" -Type Header $licenceColour = if ($headroom.Available -le 0) { 'Red' } else { 'Green' } Write-Host ("Active users: $($headroom.InUse)/$($headroom.Limit) " + "($($headroom.Available) free)") -ForegroundColor $licenceColour Write-Host "`nSeeded users ($($userReport.Count)):" -ForegroundColor Cyan $userReport | Format-Table Login, Status, Department, Clearance, Contractor -AutoSize | Out-String | Write-Host Write-Host "Groups ($($groupReport.Count)):" -ForegroundColor Cyan $groupReport | Format-Table Name, MemberCount -AutoSize | Out-String | Write-Host Write-Host "Group rules ($($ruleReport.Count)):" -ForegroundColor Cyan $ruleReport | Format-Table Name, Status, Expression -AutoSize | Out-String | Write-Host Write-Host "User types ($(@($report.UserTypes).Count) beyond the default):" -ForegroundColor Cyan $report.UserTypes | Format-Table Name, DisplayName -AutoSize | Out-String | Write-Host $attrHeading = 'Custom attributes ({0} across all schemas):' -f @($customAttributes).Count Write-Host $attrHeading -ForegroundColor Cyan $customAttributes | Format-Table UserType, Name, Type, Title -AutoSize | Out-String | Write-Host Write-Host "Apps ($(@($report.Apps).Count)):" -ForegroundColor Cyan $report.Apps | Format-Table Label, SignOnMode, GroupCount, UserCount -AutoSize | Out-String | Write-Host Write-Host "Network zones ($(@($report.NetworkZones).Count)):" -ForegroundColor Cyan $report.NetworkZones | Select-Object Name, Usage, Status, @{ Name = 'Gateways'; Expression = { $_.Gateways -join ', ' } } | Format-Table -AutoSize | Out-String | Write-Host Write-Host "Policies ($(@($report.Policies).Count)):" -ForegroundColor Cyan $report.Policies | Format-Table Name, Type, Status, Priority -AutoSize | Out-String | Write-Host Write-Host "Trusted origins ($(@($report.TrustedOrigins).Count)):" -ForegroundColor Cyan $report.TrustedOrigins | Select-Object Name, Origin, @{ Name = 'Scopes'; Expression = { $_.Scopes -join ', ' } } | Format-Table -AutoSize | Out-String | Write-Host Write-Host "Event hooks ($(@($report.EventHooks).Count)):" -ForegroundColor Cyan $report.EventHooks | Select-Object Name, Status, Uri, @{ Name = 'Events'; Expression = { @($_.Events).Count } } | Format-Table -AutoSize | Out-String | Write-Host Write-Host "Linked objects ($(@($report.LinkedObjects).Count)):" -ForegroundColor Cyan $report.LinkedObjects | Format-Table Primary, Associated -AutoSize | Out-String | Write-Host } 'JSON' { $json = $report | ConvertTo-Json -Depth 10 [System.IO.File]::WriteAllBytes($OutputPath, [System.Text.Encoding]::UTF8.GetBytes($json)) Write-TestMessage -Message "JSON report written to $OutputPath" -Type Success } 'CSV' { if (-not (Test-Path -Path $OutputPath)) { $null = New-Item -Path $OutputPath -ItemType Directory -Force } # -Encoding UTF8 is not optional here. The default on Windows PowerShell is ASCII, # and the seeded directory is full of names that do not survive it. $userReport | Export-Csv -Path (Join-Path $OutputPath 'OktaLabUsers.csv') ` -NoTypeInformation -Encoding UTF8 $groupReport | Select-Object Name, Description, MemberCount, @{ Name = 'Members'; Expression = { $_.Members -join ';' } } | Export-Csv -Path (Join-Path $OutputPath 'OktaLabGroups.csv') -NoTypeInformation -Encoding UTF8 $ruleReport | Select-Object Name, Status, Expression | Export-Csv -Path (Join-Path $OutputPath 'OktaLabRules.csv') -NoTypeInformation -Encoding UTF8 $report.Apps | Select-Object Label, SignOnMode, Status, GroupCount, UserCount, @{ Name = 'DirectUsers'; Expression = { $_.DirectUsers -join ';' } } | Export-Csv -Path (Join-Path $OutputPath 'OktaLabApps.csv') -NoTypeInformation -Encoding UTF8 $report.Policies | Export-Csv -Path (Join-Path $OutputPath 'OktaLabPolicies.csv') ` -NoTypeInformation -Encoding UTF8 $customAttributes | Export-Csv -Path (Join-Path $OutputPath 'OktaLabAttributes.csv') ` -NoTypeInformation -Encoding UTF8 Write-TestMessage -Message "CSV reports written to $OutputPath" -Type Success } 'HTML' { $style = @' <style> body { font-family: Segoe UI, system-ui, sans-serif; margin: 2rem; color: #1f2933; } h1 { border-bottom: 2px solid #005b9f; padding-bottom: .3rem; } h2 { margin-top: 2rem; color: #005b9f; } table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; } th, td { border: 1px solid #d3d8de; padding: .4rem .6rem; text-align: left; font-size: .9rem; } th { background: #eef3f8; } tr:nth-child(even) td { background: #fafbfc; } .warn { color: #b34700; font-weight: 600; } </style> '@ $licenceLine = "Active users: $($headroom.InUse) of $($headroom.Limit), $($headroom.Available) free" if ($headroom.Available -le 0) { $licenceLine = "<span class='warn'>$licenceLine</span>" } $sections = @( "<h1>Okta Test Environment</h1>" ("<p>$($report.OrgUrl) — prefix $($report.Prefix) — " + "generated $($report.GeneratedUtc)</p>") "<p>$licenceLine</p>" "<h2>Users</h2>" ($userReport | ConvertTo-Html -Fragment) "<h2>Groups</h2>" ($groupReport | Select-Object Name, MemberCount, @{ Name = 'Members'; Expression = { $_.Members -join ', ' } } | ConvertTo-Html -Fragment) "<h2>Group rules</h2>" ($ruleReport | Select-Object Name, Status, Expression | ConvertTo-Html -Fragment) "<h2>User types</h2>" ($report.UserTypes | ConvertTo-Html -Fragment) "<h2>Custom attributes</h2>" ($customAttributes | ConvertTo-Html -Fragment) "<h2>Network zones</h2>" ($report.NetworkZones | Select-Object Name, Usage, Status, @{ Name = 'Gateways'; Expression = { $_.Gateways -join ', ' } } | ConvertTo-Html -Fragment) "<h2>Policies</h2>" ($report.Policies | ConvertTo-Html -Fragment) "<h2>Trusted origins</h2>" ($report.TrustedOrigins | Select-Object Name, Origin, @{ Name = 'Scopes'; Expression = { $_.Scopes -join ', ' } } | ConvertTo-Html -Fragment) "<h2>Event hooks</h2>" ($report.EventHooks | Select-Object Name, Status, Uri, @{ Name = 'Events'; Expression = { $_.Events -join ', ' } } | ConvertTo-Html -Fragment) "<h2>Linked objects</h2>" ($report.LinkedObjects | ConvertTo-Html -Fragment) "<h2>Apps</h2>" ($report.Apps | Select-Object Label, SignOnMode, Status, GroupCount, UserCount, @{ Name = 'DirectUsers'; Expression = { $_.DirectUsers -join ', ' } } | ConvertTo-Html -Fragment) ) -join "`n" $html = ConvertTo-Html -Head "<title>Okta Test Environment</title>$style" -Body $sections | Out-String [System.IO.File]::WriteAllBytes($OutputPath, [System.Text.Encoding]::UTF8.GetBytes($html)) Write-TestMessage -Message "HTML report written to $OutputPath" -Type Success } } if ($PassThru) { return $report } } |