Scripts/Get-CAManagerEntraProxyInventory.ps1
|
<#
.SYNOPSIS Read-only inventory of the Entra (Azure AD) Application Proxy apps that front a client's PKI - the CRL/AIA app and the OCSP app. Captures every value CA-Manager.ps1 needs to reproduce (or diff against) a hand-built App Proxy publishing setup. Nothing is created or changed. .DESCRIPTION Run from any workstation with internet + the Microsoft.Graph.Authentication module. You sign in interactively as a Global Reader / Application Administrator (or higher). Read-only Graph scopes only. Writes a timestamped .txt (transcript) to -OutputDir. Dumps, in order: A. Tenant / signed-in context B. All App Proxy connector groups + every connector (machine, external IP, status, version) C. Every application that has onPremisesPublishing configured - narrowed to the PKI ones by -AppNameLike / -AppId (default: anything whose displayName or external/internal URL contains crl, ocsp, aia, pki, cert, or "certenroll"). For each: - onPremisesPublishing (v1.0 + beta): internalUrl, externalUrl, externalAuthenticationType (aadPreAuthentication = pre-auth / passthru = passthrough), server timeout, all the cookie / header-translation toggles, singleSignOnSettings, the custom-domain SSL cert metadata (subject / thumbprint / expiry), and (beta) segmentsConfiguration for wildcard apps - the connector group the app is assigned to - the app's identifierUris + web.redirectUris (pre-auth reply URLs) - its servicePrincipal: appRoleAssignmentRequired (true = only assigned principals get through pre-auth), accountEnabled, preferredSingleSignOnMode, loginUrl, notes, tags - appRoleAssignedTo: who (users / groups) is allowed through when pre-auth + assignment required Send the .txt back. .PARAMETER AppNameLike Substring(s) matched (case-insensitive) against each onPrem-publishing app's displayName, externalUrl and internalUrl. Default covers the usual PKI naming. Pass your own to narrow/widen. .PARAMETER AppId One or more application (client) IDs or directory object IDs - bypasses the name filter and dumps exactly those. .PARAMETER OutputDir Where the timestamped .txt is written. Default C:\Admin\CAInventory. .PARAMETER TenantId Optional - pass to force a specific tenant on Connect-MgGraph (useful for guest / multi-tenant accounts). Omit for your home tenant. .NOTES Needs the Microsoft.Graph.Authentication module only (not the full Microsoft.Graph meta-module): Install-Module Microsoft.Graph.Authentication -Scope CurrentUser Scopes requested (all read-only): Application.Read.All, Directory.Read.All, Group.Read.All. Reading onPremisesPublishingProfiles also needs the account to be at least Global Reader. #> [CmdletBinding()] param( [string[]]$AppNameLike = @('crl', 'ocsp', 'aia', 'pki', 'cert', 'certenroll', 'msappproxy'), [string[]]$AppId, [string]$OutputDir = "C:\Admin\CAInventory", [string]$TenantId ) $ErrorActionPreference = 'Continue' if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { Write-Host "Microsoft.Graph.Authentication is not installed. Install it (no admin needed) with:" -ForegroundColor Yellow Write-Host " Install-Module Microsoft.Graph.Authentication -Scope CurrentUser" -ForegroundColor Cyan return } Import-Module Microsoft.Graph.Authentication -ErrorAction Stop if (-not (Test-Path $OutputDir)) { $null = New-Item -ItemType Directory -Path $OutputDir -Force } $stamp = Get-Date -Format 'yyyyMMdd_HHmmss' $outFile = Join-Path $OutputDir "EntraProxyInventory_${stamp}.txt" try { $null = Start-Transcript -Path $outFile -Force } catch { } function Section { param([string]$Text) Write-Host ""; Write-Host ("=" * 78) -ForegroundColor Cyan Write-Host " $Text" -ForegroundColor Cyan Write-Host ("=" * 78) -ForegroundColor Cyan } function Sub { param([string]$Text) Write-Host ""; Write-Host "--- $Text ---" -ForegroundColor Gray } function Note { param([string]$Text) Write-Host " [note] $Text" -ForegroundColor Yellow } function Dump { param($Obj, [int]$Depth = 6) if ($null -eq $Obj) { Write-Host " (null)"; return } $Obj | ConvertTo-Json -Depth $Depth | Write-Host } # --- Graph GET wrappers --------------------------------------------------------------- # Some of the App Proxy endpoints legitimately 404/400 for a given tenant/group type (e.g. asking # a passthroughAuthentication group for /members). We don't want each of those to dump a full HTTP # error block into the transcript, so - if the module build supports it - use -SkipHttpErrorCheck # and inspect the status code instead of catching a thrown exception. $script:GraphCanSkipErr = (Get-Command Invoke-MgGraphRequest).Parameters.ContainsKey('SkipHttpErrorCheck') function Invoke-Graph { param([string]$Uri) if ($script:GraphCanSkipErr) { $sc = $null $b = Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject -SkipHttpErrorCheck -StatusCodeVariable sc -ErrorAction SilentlyContinue return [pscustomobject]@{ ok = ($sc -ge 200 -and $sc -lt 300); status = $sc; body = $b } } try { $b = Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject -ErrorAction Stop return [pscustomobject]@{ ok = $true; status = 200; body = $b } } catch { $code = $null; try { $code = [int]$_.Exception.Response.StatusCode.value__ } catch { } return [pscustomobject]@{ ok = $false; status = $code; body = $null } } } # follows @odata.nextLink; ENUMERATES the flattened .value set (call sites wrap in @() when they # need a guaranteed array). Do NOT `,$items` here - when this function's output is piped, the # outer wrapper unrolls and the downstream cmdlet receives the whole inner array as a single item. function Get-GraphAll { param([string]$Uri, [switch]$Quiet) $items = @(); $next = $Uri while ($next) { $res = Invoke-Graph $next if (-not $res.ok) { if (-not $Quiet) { Write-Host " (skip) $next -> HTTP $($res.status)" -ForegroundColor DarkYellow }; break } $b = $res.body if ($null -ne $b.value) { $items += $b.value } elseif ($b) { $items += $b } $next = $b.'@odata.nextLink' } $items } function Get-GraphOne { param([string]$Uri, [switch]$Quiet) $res = Invoke-Graph $Uri if ($res.ok) { return $res.body } if (-not $Quiet) { Write-Host " (skip) $Uri -> HTTP $($res.status)" -ForegroundColor DarkYellow } return $null } # App Proxy (connector groups/connectors) and the full onPremisesPublishing object on applications # live ONLY under the BETA endpoint - v1.0 returns "Resource not found for the segment # 'onPremisesPublishingProfiles'" and doesn't populate application.onPremisesPublishing. Everything # in sections B and C therefore uses beta. $Beta = "https://graph.microsoft.com/beta" # ---- connect -------------------------------------------------------------------------- $connectArgs = @{ Scopes = @('Application.Read.All', 'Directory.Read.All', 'Group.Read.All') } if ($TenantId) { $connectArgs.TenantId = $TenantId } try { $connectArgs.NoWelcome = $true; Connect-MgGraph @connectArgs -ErrorAction Stop } catch { # older module builds don't have -NoWelcome [void]$connectArgs.Remove('NoWelcome') Connect-MgGraph @connectArgs -ErrorAction Stop } Write-Host "CA Manager - Entra Application Proxy inventory" -ForegroundColor Green Write-Host "When : $(Get-Date)" Write-Host "Output : $outFile" Note "Read-only. Nothing in Entra is created or changed by this script." # ===================================================================================== Section "A. Tenant / signed-in context" # ===================================================================================== $ctx = Get-MgContext $ctx | Format-List Account, TenantId, Scopes, AppName, Environment | Out-String | Write-Host $org = Get-GraphOne "https://graph.microsoft.com/v1.0/organization?`$select=displayName,id,verifiedDomains" if ($org) { $org.value | ForEach-Object { Write-Host " Tenant: $($_.displayName) ($($_.id))"; $_.verifiedDomains | ForEach-Object { Write-Host " domain: $($_.name) default=$($_.isDefault) initial=$($_.isInitial)" } } } # ===================================================================================== Section "B. App Proxy connector groups + connectors (beta)" # ===================================================================================== Sub "connector groups" $cgs = @() try { $cgs = Get-GraphAll "$Beta/onPremisesPublishingProfiles/applicationProxy/connectorGroups" } catch { Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red } foreach ($cg in $cgs) { Write-Host "" Write-Host " [$($cg.name)] id=$($cg.id) region=$($cg.region) default=$($cg.isDefault) type=$($cg.connectorGroupType)" # /members and /applications only exist for applicationProxy-type groups - passthroughAuthentication / # exchangeOnline / adfs groups 404 on those sub-paths, so don't even ask. if ($cg.connectorGroupType -ne 'applicationProxy') { Write-Host " (not an applicationProxy group - skipping members/apps)"; continue } foreach ($m in (Get-GraphAll "$Beta/onPremisesPublishingProfiles/applicationProxy/connectorGroups/$($cg.id)/members" -Quiet)) { Write-Host (" connector: {0,-28} extIP={1,-16} status={2,-10} ver={3}" -f $m.machineName, $m.externalIp, $m.status, $m.version) } $apps = Get-GraphAll "$Beta/onPremisesPublishingProfiles/applicationProxy/connectorGroups/$($cg.id)/applications?`$select=id,displayName" -Quiet if ($apps) { Write-Host " apps assigned: $(( $apps | ForEach-Object { $_.displayName }) -join ', ')" } } Sub "all connectors (whole tenant)" try { Get-GraphAll "$Beta/onPremisesPublishingProfiles/applicationProxy/connectors" | ForEach-Object { Write-Host (" {0,-28} extIP={1,-16} status={2,-10} ver={3} id={4}" -f $_.machineName, $_.externalIp, $_.status, $_.version, $_.id) } } catch { Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red } # ===================================================================================== Section "C. Application Proxy applications (PKI)" # ===================================================================================== Sub "enumerating App Proxy apps via the servicePrincipal tag 'WindowsAzureActiveDirectoryOnPremApp'" # The reliable enumeration path. (Beta /applications?$select=...onPremisesPublishing... on the # COLLECTION throws InvalidGuid_BadRequest '[sourceEntityId]', and /applications(appId='x') as a # key segment returns a raw IIS 404 on beta - so: tag-scan the SPs, then resolve each real # application object by $filter=appId eq 'x'.) $allSps = @(Get-GraphAll "$Beta/servicePrincipals?`$select=id,appId,displayName,tags&`$top=999") $onpremSps = @($allSps | Where-Object { $_.tags -contains 'WindowsAzureActiveDirectoryOnPremApp' }) Write-Host " servicePrincipals scanned: $($allSps.Count); tagged as App Proxy: $($onpremSps.Count)" foreach ($s in $onpremSps) { Write-Host (" - {0,-30} appId={1}" -f $s.displayName, $s.appId) } # IMPORTANT: never put onPremisesPublishing in a $select on the /applications COLLECTION (even with # a $filter) - it throws InvalidGuid_BadRequest '[sourceEntityId]'. Resolve the app object id here # with a safe select (v1.0 is fine and reliable for appId eq), then fetch onPremisesPublishing as a # SINGLE-item read below. $appSelect = 'id,appId,displayName,identifierUris,web,tags,createdDateTime' $targets = foreach ($sp in $onpremSps) { $appObj = (Get-GraphOne "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$($sp.appId)'&`$select=$appSelect").value | Select-Object -First 1 if (-not $appObj) { $appObj = (Get-GraphOne "$Beta/applications?`$filter=appId eq '$($sp.appId)'&`$select=$appSelect").value | Select-Object -First 1 } [pscustomobject]@{ Sp = $sp; App = $appObj } } # narrow to the PKI ones (match against SP displayName + appId + app displayName/URLs) if ($AppId) { $picked = $targets | Where-Object { $AppId -contains $_.Sp.appId -or $AppId -contains $_.Sp.id -or $AppId -contains $_.App.id } } else { $picked = $targets | Where-Object { $hay = (@($_.Sp.displayName, $_.App.displayName, $_.App.onPremisesPublishing.externalUrl, $_.App.onPremisesPublishing.internalUrl) -join ' ').ToLower() $m = $false foreach ($n in $AppNameLike) { if ($hay -and $hay.Contains($n.ToLower())) { $m = $true; break } } $m } if (-not $picked) { Note "No app matched -AppNameLike ($($AppNameLike -join ', ')). Dumping ALL App Proxy apps so you can identify them." $picked = $targets } } foreach ($t in $picked) { $sp = $t.Sp $app = $t.App Section " APP: $($sp.displayName)" Write-Host " appId (client id) : $($sp.appId)" Write-Host " app object id : $($app.id)" Write-Host " sp object id : $($sp.id)" Write-Host " created : $($app.createdDateTime)" Write-Host " identifierUris : $($app.identifierUris -join ', ')" Write-Host " web.redirectUris : $($app.web.redirectUris -join ', ')" Write-Host " web.homePageUrl : $($app.web.homePageUrl)" Write-Host " app tags : $($app.tags -join ', ')" if (-not $app.id) { Note "Could not resolve the application object for appId $($sp.appId) - onPremisesPublishing / connectorGroup unavailable for this one. (servicePrincipal detail below is still valid.)" } else { Sub "onPremisesPublishing (the App Proxy config we need)" $full = Get-GraphOne "$Beta/applications/$($app.id)?`$select=id,displayName,identifierUris,web,onPremisesPublishing" $pub = $full.onPremisesPublishing if (-not $pub) { $full = Get-GraphOne "$Beta/applications/$($app.id)"; $pub = $full.onPremisesPublishing } # plain single-item GET if ($pub) { Dump $pub 12 } else { Write-Host " (onPremisesPublishing still empty - dumping the whole application object)"; if ($full) { Dump $full 8 } } Sub "assigned connector group" $cg = Get-GraphOne "$Beta/applications/$($app.id)/connectorGroup" if ($cg) { Write-Host " [$($cg.name)] id=$($cg.id) region=$($cg.region) type=$($cg.connectorGroupType)" } else { Write-Host " (none returned / default)" } } Sub "servicePrincipal" # re-fetch the SP as a single object with the full property set (the enumeration select was lean) $spFull = Get-GraphOne "$Beta/servicePrincipals/$($sp.id)?`$select=id,appId,displayName,accountEnabled,appRoleAssignmentRequired,preferredSingleSignOnMode,loginUrl,notificationEmailAddresses,preferredTokenSigningKeyThumbprint,tags,keyCredentials,samlSingleSignOnSettings" if (-not $spFull) { $spFull = $sp } $spFull | Select-Object id, displayName, accountEnabled, appRoleAssignmentRequired, preferredSingleSignOnMode, loginUrl, @{n='notificationEmailAddresses';e={ $_.notificationEmailAddresses -join ', ' }}, preferredTokenSigningKeyThumbprint, @{n='tags';e={ $_.tags -join ', ' }} | Format-List | Out-String | Write-Host Note "appRoleAssignmentRequired = True => only assigned users/groups can reach it (only matters if externalAuthenticationType = aadPreAuthentication; for PKI it should be 'passthru')." Sub "appRoleAssignedTo (who is allowed through, if pre-auth)" $assigns = @(Get-GraphAll "$Beta/servicePrincipals/$($sp.id)/appRoleAssignedTo" -Quiet) if ($assigns) { foreach ($a in $assigns) { Write-Host (" {0,-16} {1}" -f $a.principalType, $a.principalDisplayName) } } else { Write-Host " (no explicit assignments)" } Sub "SSO / token config" $spFull | Select-Object ` @{n='keyCredentials';e={ ($_.keyCredentials | ForEach-Object { "$($_.usage):$($_.displayName):exp=$($_.endDateTime)" }) -join ' | ' }}, @{n='samlSingleSignOnSettings';e={ $_.samlSingleSignOnSettings | ConvertTo-Json -Compress -Depth 5 }} | Format-List | Out-String | Write-Host } Section "DONE" Write-Host "Inventory written to:" -ForegroundColor Green Write-Host " $outFile" -ForegroundColor Green Write-Host "" Write-Host "Send that file back." -ForegroundColor Cyan try { $null = Disconnect-MgGraph } catch { } try { $null = Stop-Transcript } catch { } Write-Host "" Write-Host "(press Enter to close)" try { $null = Read-Host } catch { } |