Private/CAGraph.ps1
|
<#
.SYNOPSIS CA Manager - Microsoft Graph connect + request helpers, shared by the App Proxy wizard (Modules\CAAppProxy.ps1, dashboard menu 6). Requires Modules\CACore.ps1 (Invoke-CAStep) and the Microsoft.Graph.Authentication module. .DESCRIPTION These are ports of the read helpers already built and debugged in PushableTools\CAManager\Get-CAManagerEntraProxyInventory.ps1 (which stays a standalone script - it has to run BEFORE this tool exists). Behaviour is identical, including: - Invoke-CAGraph uses -SkipHttpErrorCheck / -StatusCodeVariable when the module build has them, so an expected 404/400 does not dump a full HTTP error block. - Get-CAGraphAll follows @odata.nextLink and ENUMERATES its result; it must NOT `,$items`-wrap (a piped wrapper unrolls and the downstream cmdlet gets the whole inner array as one item). - App Proxy endpoints (onPremisesPublishingProfiles/applicationProxy/*) and the full onPremisesPublishing object on applications live ONLY under /beta. Invoke-CAGraphStep is the WRITE path: it wraps a POST/PATCH/PUT/DELETE in Invoke-CAStep so every mutation is dry-run aware. Plain GET reads do NOT go through Invoke-CAStep - they are non-mutating and must run even in DRY RUN so the plan preview reflects the real tenant (same principle as CATemplates doing a live [ADSI]::Exists in dry-run). #> $script:CAGraphV1 = 'https://graph.microsoft.com/v1.0' $script:CAGraphBeta = 'https://graph.microsoft.com/beta' $script:CAGraphCanSkipErr = $null # resolved lazily on first call # --------------------------------------------------------------------------- function Repair-CAModulePath { <# .SYNOPSIS Ensures the standard per-user + AllUsers module directories are on $env:PSModulePath. The Entra private-network connector installer (run by menu 6 itself) rewrites PSModulePath and drops the CurrentUser path, which then hides an otherwise-installed Microsoft.Graph.Authentication. .NOTES Rebuilds PSModulePath from scratch out of Test-Path-VALIDATED directories rather than trusting the inherited string's own delimiters. Found live 2026-09-10 on a client's CA server: the inherited $env:PSModulePath had two entries glued together with no ';' between them (module name auto-discovery - Import-Module PowerShellGet by name - then fails with "no valid module file was found in any module directory" even though the real directory is right there on disk and a direct-path Import-Module succeeds). The old version only string-matched -notin against a naive -split ';' and would happily leave a malformed/garbage entry in place; this one keeps only entries that are real, existing directories, so a corrupted segment is silently dropped instead of propagated forward on every relaunch. #> $want = @( (Join-Path $HOME 'Documents\WindowsPowerShell\Modules') (Join-Path $HOME 'Documents\PowerShell\Modules') (Join-Path $env:ProgramFiles 'WindowsPowerShell\Modules') # the system dir - ServerManager / ADCSDeployment / ADCSAdministration live here. Should # never be absent, but the Entra connector installer rewrites the persistent PSModulePath # env var and an elevated relaunch can inherit a version that's missing it. (Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\Modules') ) | ForEach-Object { $_.TrimEnd('\') } $existing = @($env:PSModulePath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container -ErrorAction SilentlyContinue) }) $env:PSModulePath = (@($want + $existing) | Select-Object -Unique) -join ';' } function Connect-CAGraph { <# .SYNOPSIS Interactive Connect-MgGraph with the write scopes menu 6 needs. Returns Get-MgContext, or $null if the module is missing / sign-in failed. #> param( [string[]]$Scopes = @('Application.ReadWrite.All', 'Directory.ReadWrite.All', 'OnPremisesPublishingProfiles.ReadWrite.All'), [string]$TenantId ) Repair-CAModulePath if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { Write-Host " Microsoft.Graph.Authentication is not installed - attempting to install it now..." -ForegroundColor Yellow if (Get-Command Install-CAGraphModule -ErrorAction SilentlyContinue) { Install-CAGraphModule } Repair-CAModulePath if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { Write-Host " Still not available. Install it manually (see the note above), then re-run menu 6." -ForegroundColor Red return $null } } Import-Module Microsoft.Graph.Authentication -ErrorAction Stop $connectArgs = @{ Scopes = $Scopes } if ($TenantId) { $connectArgs.TenantId = $TenantId } try { $connectArgs.NoWelcome = $true Connect-MgGraph @connectArgs -ErrorAction Stop } catch { # older module builds have no -NoWelcome [void]$connectArgs.Remove('NoWelcome') try { Connect-MgGraph @connectArgs -ErrorAction Stop } catch { Write-Host " Graph sign-in failed: $($_.Exception.Message)" -ForegroundColor Red return $null } } return (Get-MgContext) } # --------------------------------------------------------------------------- function Invoke-CAGraph { <# .SYNOPSIS One Graph request. Returns [pscustomobject]@{ ok; status; body }. Never throws on an HTTP error - the caller inspects .ok / .status. #> param( [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')][string]$Method = 'GET', [Parameter(Mandatory)][string]$Uri, $Body ) if ($null -eq $script:CAGraphCanSkipErr) { $script:CAGraphCanSkipErr = (Get-Command Invoke-MgGraphRequest).Parameters.ContainsKey('SkipHttpErrorCheck') } $reqArgs = @{ Method = $Method; Uri = $Uri; OutputType = 'PSObject' } if ($null -ne $Body) { $reqArgs.Body = if ($Body -is [string]) { $Body } else { ($Body | ConvertTo-Json -Depth 12 -Compress) } $reqArgs.ContentType = 'application/json' } if ($script:CAGraphCanSkipErr) { $sc = $null $b = Invoke-MgGraphRequest @reqArgs -SkipHttpErrorCheck -StatusCodeVariable sc -ErrorAction SilentlyContinue return [pscustomobject]@{ ok = ($sc -ge 200 -and $sc -lt 300); status = $sc; body = $b } } try { $b = Invoke-MgGraphRequest @reqArgs -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 } } } # --------------------------------------------------------------------------- function Get-CAGraphAll { <# .SYNOPSIS GET a collection, following @odata.nextLink. ENUMERATES the flattened .value set - call sites wrap in @() when they need a guaranteed array. Do NOT `,$items`-wrap here. #> param([Parameter(Mandatory)][string]$Uri, [switch]$Quiet) $items = @(); $next = $Uri while ($next) { $res = Invoke-CAGraph -Method GET -Uri $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-CAGraphOne { <# .SYNOPSIS GET a single resource. Returns the body, or $null on any HTTP error. #> param([Parameter(Mandatory)][string]$Uri, [switch]$Quiet) $res = Invoke-CAGraph -Method GET -Uri $Uri if ($res.ok) { return $res.body } if (-not $Quiet) { Write-Host " (skip) $Uri -> HTTP $($res.status)" -ForegroundColor DarkYellow } return $null } # --------------------------------------------------------------------------- function Invoke-CAGraphStep { <# .SYNOPSIS A MUTATING Graph call (POST/PATCH/PUT/DELETE) routed through Invoke-CAStep so it is dry-run aware. In DRY RUN it prints "<METHOD> <uri>" + the compact JSON body and returns $null. In APPLY it performs the call and THROWS on any non-2xx (so a Graph failure does not slip past Invoke-CAStep's "didn't throw == done"). Returns the response body. #> param( [Parameter(Mandatory)][string]$Description, [Parameter(Mandatory)][ValidateSet('POST', 'PATCH', 'PUT', 'DELETE')][string]$Method, [Parameter(Mandatory)][string]$Uri, $Body ) $jsonPreview = if ($null -ne $Body) { if ($Body -is [string]) { $Body } else { ($Body | ConvertTo-Json -Depth 12 -Compress) } } else { '(no body)' } $res = Invoke-CAStep -Description $Description ` -Commands @("$Method $Uri", " $jsonPreview") ` -Action { $r = Invoke-CAGraph -Method $Method -Uri $Uri -Body $Body if (-not $r.ok) { $detail = try { $r.body | ConvertTo-Json -Depth 6 -Compress } catch { '' } throw "Graph $Method $Uri -> HTTP $($r.status) $detail" } $r.body } return $res.Output } # --------------------------------------------------------------------------- function Get-CAGraphTenantInitialDomain { <# .SYNOPSIS The tenant's initial *.onmicrosoft.com domain LABEL (e.g. 'contoso' from 'contoso.onmicrosoft.com'), used to compose the App Proxy hostnames. $null if unavailable. #> $org = Get-CAGraphOne "$script:CAGraphV1/organization?`$select=verifiedDomains" if (-not $org) { return $null } $row = @($org.value)[0] if (-not $row) { return $null } $initial = $row.verifiedDomains | Where-Object { $_.isInitial } | Select-Object -First 1 if (-not $initial) { $initial = $row.verifiedDomains | Where-Object { $_.name -match '(?i)\.onmicrosoft\.com$' } | Select-Object -First 1 } if (-not $initial) { return $null } return ($initial.name -replace '(?i)\.onmicrosoft\.com$', '') } # --------------------------------------------------------------------------- function Test-CAGraphWriteScopes { <# .SYNOPSIS Returns the required write scopes that the current Graph session is MISSING (empty == all present). #> param( [string[]]$Required = @('Application.ReadWrite.All', 'Directory.ReadWrite.All', 'OnPremisesPublishingProfiles.ReadWrite.All') ) $have = @() try { $have = @((Get-MgContext).Scopes) } catch { } return @($Required | Where-Object { $_ -notin $have }) } |