Public/Connect-Inforcer.ps1
|
<# .SYNOPSIS Establishes a secure connection to the Inforcer REST API. .DESCRIPTION Creates an authenticated session using an API key. You can specify -Region (uk, eu, us, anz) or -BaseUrl for custom endpoints. The API key is stored as a SecureString. Before returning Connected, a minimal API call validates the key; if it fails (e.g. wrong key for the endpoint, or an expired/revoked key), the connection is not established and an error is returned. A key that is valid but lacks the scope for the probe endpoint still connects — no single scope is privileged for validation. .PARAMETER ApiKey The Inforcer API key. Can be SecureString or String (converted to SecureString). .PARAMETER Region Region for production API. Valid: uk, eu, us, anz. Default: uk. Ignored when -BaseUrl is set. .PARAMETER BaseUrl Optional custom base URL. When set, -Region is ignored. .EXAMPLE Connect-Inforcer -ApiKey "your-api-key" -Region uk .EXAMPLE $key = Read-Host -AsSecureString -Prompt "API Key"; Connect-Inforcer -ApiKey $key -Region uk .EXAMPLE Connect-Inforcer -ApiKey $key -BaseUrl "https://api.example.com/api" Connects using a custom base URL (use your actual API base URL in place of the example). .EXAMPLE Connect-Inforcer -ApiKey "your-api-key" -Region uk -FetchGraphData Connects to Inforcer and also launches Microsoft Graph interactive sign-in for group name resolution. .PARAMETER FetchGraphData Also connect to Microsoft Graph via interactive sign-in. This enables group name resolution in Export-InforcerTenantDocumentation. Requires Microsoft.Graph.Authentication module (auto-installed if missing). .PARAMETER PassThru When specified, returns the session hashtable to the pipeline in addition to storing it in the module-scoped session variable. Use this to capture sessions for cross-account comparison with Compare-InforcerEnvironments. .OUTPUTS PSObject with Status, Region, BaseUrl, ConnectedAt. .LINK https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#connect-inforcer .LINK Disconnect-Inforcer .LINK Get-InforcerTenant #> function Connect-Inforcer { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Intentional convenience: users may pass a plain-text API key which is converted to SecureString for storage.')] [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([PSObject])] param( [Parameter(Mandatory = $true, Position = 0)] [Alias('Key')] [object]$ApiKey, [Parameter(Mandatory = $false, Position = 1)] [ValidateSet('anz', 'eu', 'uk', 'us', IgnoreCase = $true)] [string]$Region = 'uk', [Parameter(Mandatory = $false)] [string]$BaseUrl, [Parameter(Mandatory = $false)] [switch]$FetchGraphData, [Parameter(Mandatory = $false)] [switch]$PassThru ) $rawApiKey = $ApiKey while ($rawApiKey -is [PSObject] -and $rawApiKey.BaseObject) { $rawApiKey = $rawApiKey.BaseObject } $secureApiKey = $null if ($rawApiKey -is [System.Security.SecureString]) { $secureApiKey = $rawApiKey } elseif ($rawApiKey -is [string]) { $secureApiKey = ConvertTo-SecureString -String $rawApiKey -AsPlainText -Force } else { Write-Error -Message "ApiKey must be a String or SecureString. Received: $($rawApiKey.GetType().Name)" ` -ErrorId 'InvalidApiKeyType' -Category InvalidArgument return } $plain = ConvertFrom-InforcerSecureString -SecureString $secureApiKey if ([string]::IsNullOrWhiteSpace($plain)) { Write-Error -Message 'API key cannot be empty.' -ErrorId 'EmptyApiKey' -Category InvalidArgument return } try { $baseUrlValue = Get-InforcerBaseUrl -Region $Region -BaseUrl $BaseUrl } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidRegion' -Category InvalidArgument return } if (!$PSCmdlet.ShouldProcess('Inforcer session', 'Connect')) { return } # Validate the API key with a minimal request before reporting Connected. # # Validation principle: Connect-Inforcer must accept ANY valid key regardless of scope. # We pick /beta/baselines as a probe target because it's a common endpoint, but the result # is interpreted by RESPONSE SHAPE, not just status code: # # * 200 → key valid, full scope for this endpoint # * 4xx with Inforcer-app error envelope → key valid; APIM accepted the subscription, # the Inforcer app rejected the scope. That's # proof the subscription works. # * 4xx whose errors[] names a key-lifecycle # problem ("API key has expired") → key itself is dead, not a scope gap. Error out. # * 401 with APIM gateway envelope → APIM rejected the subscription itself. Real # auth failure — error out. # * Other 4xx/5xx → propagate the message. # # APIM gateway envelope shape (rejection): { "statusCode": 401, "message": "Access denied due to invalid subscription key..." } # Inforcer app envelope shape (scope deny): { "success": false, "errorCode": "forbidden", "message": "...", "errors": [...] } $validateHeaders = @{ 'Inf-Api-Key' = $plain 'Accept' = 'application/json' } $probeUri = $baseUrlValue.TrimEnd('/') + '/beta/baselines' $probeSucceeded = $false $probeStatusCode = 0 $probeApiMessage = $null $probeErrorsText = $null # errors[] only, without the generic top-level message $probeEnvelope = 'unknown' # 'inforcer' | 'apim' | 'unknown' $probeRawError = $null # Use Invoke-WebRequest -SkipHttpErrorCheck (PS7+) so 4xx/5xx don't throw — keeps the # parent's error stream / -ErrorVariable clean when validation goes through the # "envelope-shape says key is valid" path even on 403. try { # -TimeoutSec 10 prevents Connect from hanging indefinitely on slow networks / proxies / # captive portals. The probe is just a GET on /beta/baselines, so 10s is generous. $probeResponse = Invoke-WebRequest -Uri $probeUri -Method GET -Headers $validateHeaders ` -UseBasicParsing -SkipHttpErrorCheck -TimeoutSec 10 -ErrorAction Stop } catch { # Only real network errors (DNS, connection refused, TLS) land here; 4xx/5xx are # captured via the response object thanks to -SkipHttpErrorCheck. $probeRawError = $_ Write-Error -Message "Connection failed: $($_.Exception.Message)" ` -ErrorId 'ConnectionValidationFailed' -Category ConnectionError return } $probeStatusCode = [int]$probeResponse.StatusCode if ($probeStatusCode -ge 200 -and $probeStatusCode -lt 300) { $probeSucceeded = $true } else { $bodyText = if ($probeResponse.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($probeResponse.Content) } else { $probeResponse.Content -as [string] } $json = $null if ($bodyText) { try { $json = $bodyText | ConvertFrom-Json -ErrorAction Stop } catch { $json = $null } } if ($json) { $msgProp = $json.PSObject.Properties['message'] if ($msgProp) { $probeApiMessage = $msgProp.Value -as [string] } # errors[] carries the actionable detail ("API key has expired."); the top-level # message is the generic auth placeholder ("A valid API key is required to access # this endpoint.") that the API also emits for plain scope denials. Kept in its own # variable because the key-lifecycle test below must read errors[] ONLY — see there. $errorsProp = $json.PSObject.Properties['errors'] if ($errorsProp) { $probeErrorsText = Format-InforcerErrorDetail -Errors $errorsProp.Value if (-not [string]::IsNullOrWhiteSpace($probeErrorsText)) { if ([string]::IsNullOrWhiteSpace($probeApiMessage)) { $probeApiMessage = $probeErrorsText } elseif ($probeApiMessage -notlike "*$probeErrorsText*") { $probeApiMessage = "$probeApiMessage — $probeErrorsText" } } } # Envelope detection: Inforcer app responses carry success / errorCode / errors; # APIM gateway responses only carry statusCode + message (no Inforcer markers). $hasInforcerMarkers = $json.PSObject.Properties['success'] -or ` $json.PSObject.Properties['errorCode'] -or ` $json.PSObject.Properties['errors'] $isApimShape = $json.PSObject.Properties['statusCode'] -and ` $json.PSObject.Properties['message'] -and ` (-not $hasInforcerMarkers) -and ` (-not $json.PSObject.Properties['data']) if ($hasInforcerMarkers) { $probeEnvelope = 'inforcer' } elseif ($isApimShape) { $probeEnvelope = 'apim' } } } # An expired / revoked / disabled key comes back in the SAME Inforcer app envelope as a # scope denial — measured against a live environment with an expired key, right down to # errorCode 'forbidden', which a scope denial also returns: # # 403 {"data":null,"errorCode":"forbidden","success":false, # "message":"A valid API key is required to access this endpoint.", # "errors":["API key has expired."]} # # So neither status code, envelope shape nor errorCode can tell "your key is dead" (must # not connect) from "your key lacks THIS scope" (must connect — see the validation # principle above). The API has no key-introspection endpoint and no way to read a key's # scopes, so errors[] is the only signal that exists. # # Read errors[] ONLY, never the joined message: the top-level "A valid API key is required # to access this endpoint." is the generic auth placeholder the API also serves for plain # scope denials, so matching it would lock out every narrow-scope key — exactly the # any-scope-connects rule this cmdlet exists to protect. $keyRejected = $probeErrorsText -match 'expired|revoked|deactivated|disabled|not active' # Decide validation outcome from status code AND envelope shape. $keyValid = switch ($true) { $probeSucceeded { $true; break } # 200 OK $keyRejected { $false; break } # key itself is dead ($probeStatusCode -eq 403 -and $probeEnvelope -eq 'inforcer'){ $true; break } # APIM passed, scope denied ($probeStatusCode -eq 401 -and $probeEnvelope -eq 'inforcer'){ $true; break } # Same shape, different code on some routes default { $false } } if ($keyValid -and -not $probeSucceeded) { Write-Verbose "Key validated against /beta/baselines via $probeEnvelope envelope (HTTP $probeStatusCode). Subscription is active; scope for /beta/baselines is not granted, but the session is established." # Say this out loud rather than only under -Verbose. Connecting on a denied probe is # correct — a Reports.Read-only key is a legitimate caller — but the key's scopes are # unverified, so 'Connected' promises less here than it does after a 200. Without this # line the next cmdlet's 403 looks like the session lied. Write-Warning ('Connected, but the key''s scopes could not be verified: the probe on /beta/baselines returned HTTP {0}. The API subscription is live and the key is not expired. If a cmdlet now fails with 403, the key is missing that endpoint''s scope.' -f $probeStatusCode) } if (-not $keyValid) { $msg = switch ($true) { $keyRejected { "Connection failed: $($probeApiMessage.TrimEnd('.')). Generate a new API key in the Inforcer portal." break } ($probeStatusCode -eq 401 -and $probeEnvelope -eq 'apim') { if ($probeApiMessage) { "Connection failed: $probeApiMessage" } else { 'Connection failed: the API subscription key is invalid (APIM gateway rejection). Verify the key in the Inforcer portal.' } break } ($probeStatusCode -eq 429 -or ($probeStatusCode -eq 403 -and $probeApiMessage -match 'quota|rate.?limit|throttl')) { if ($probeApiMessage) { "API rate limit: $probeApiMessage" } else { 'API rate limit exceeded. Please wait and try again.' } break } ($probeStatusCode -eq 401) { if ($probeApiMessage) { "Connection failed: $probeApiMessage" } else { 'Connection failed: the API key was rejected.' } break } default { if ($probeApiMessage) { "Connection validation failed: $probeApiMessage" } elseif ($probeRawError) { "Connection validation failed: $($probeRawError.Exception.Message)" } else { 'Connection validation failed.' } } } Write-Error -Message $msg -ErrorId 'ConnectionValidationFailed' -Category AuthenticationError return } # Clear stale caches from any prior session before establishing the new one. This catches # the denial sentinels ($script:InforcerAssessmentCacheDeniedAt, ...) that would otherwise # persist after a re-key — a user who reconnects with a higher-scope key shouldn't still # see "scope required" hints from the previous one. Uses the same wildcard pattern that # Disconnect-Inforcer uses, so any future Inforcer*Cache* var is included automatically. $cacheVarsToClear = Get-Variable -Scope Script -Name 'Inforcer*Cache*' -ErrorAction SilentlyContinue if ($cacheVarsToClear) { $cacheVarsToClear | Clear-Variable -Scope Script -Force -ErrorAction SilentlyContinue Write-Verbose ('Cleared {0} stale cache variable(s) before establishing new session.' -f $cacheVarsToClear.Count) } $script:InforcerSession = @{ ApiKey = $secureApiKey BaseUrl = $baseUrlValue Region = $Region ConnectedAt = Get-Date } Write-Verbose "Successfully connected to Inforcer API at $baseUrlValue" # Best-effort prime of the Reports catalog cache so Get-InforcerReportType / # Invoke-InforcerReport argument completers show the live key list on the very # first TAB. Silent on any failure (key may lack Reports.Read, transient network, # rate limit) — the cmdlets re-prime on first invocation via Resolve-InforcerReportTypeSchema. # Skip prime when cache is already populated (cache-clear above runs first, so reaching # here with populated cache means a defensive condition we shouldn't overwrite). $skipPrime = $false if ($script:InforcerReportTypeCache -and @($script:InforcerReportTypeCache).Count -gt 0) { Write-Verbose 'Reports catalog cache already populated — skipping prime.' $skipPrime = $true } if (-not $skipPrime) { try { $catalogProbe = Invoke-WebRequest -Uri ($baseUrlValue.TrimEnd('/') + '/beta/reports/types') ` -Method GET -Headers $validateHeaders -UseBasicParsing -SkipHttpErrorCheck ` -TimeoutSec 4 -ErrorAction Stop if ([int]$catalogProbe.StatusCode -ge 200 -and [int]$catalogProbe.StatusCode -lt 300) { $catalogJson = if ($catalogProbe.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($catalogProbe.Content) } else { $catalogProbe.Content -as [string] } if ($catalogJson) { $parsed = $catalogJson | ConvertFrom-Json -ErrorAction Stop $dataProp = $parsed.PSObject.Properties['data'] $entries = if ($dataProp) { @($dataProp.Value) } else { @($parsed) } if ($entries.Count -gt 0) { $script:InforcerReportTypeCache = $entries $script:InforcerReportTypeCacheStamp = Get-Date Write-Verbose "Primed Reports catalog cache ($($entries.Count) types) for argument completion." } } } else { Write-Verbose "Skipping Reports catalog prime: HTTP $([int]$catalogProbe.StatusCode) (key likely lacks Reports.Read)." } } catch { Write-Verbose "Skipping Reports catalog prime: $($_.Exception.Message)" } } # end of if (-not $skipPrime) wrapper if ($PassThru) { # Return a clone of the session hashtable so callers have an independent copy $sessionCopy = @{ ApiKey = $script:InforcerSession.ApiKey BaseUrl = $script:InforcerSession.BaseUrl Region = $script:InforcerSession.Region ConnectedAt = $script:InforcerSession.ConnectedAt } Write-Output $sessionCopy } # Also connect to Microsoft Graph if requested if ($FetchGraphData) { Write-Host 'Connecting to Microsoft Graph...' -ForegroundColor Cyan $graphCtx = Connect-InforcerGraph -RequiredScopes @('Directory.Read.All') if ($graphCtx) { Write-Host " Graph connected as: $($graphCtx.Account)" -ForegroundColor Green } } $out = [PSCustomObject]@{ Status = 'Connected' Region = $Region BaseUrl = $baseUrlValue ConnectedAt = $script:InforcerSession.ConnectedAt } $out } |