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 api-uk.dev 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
}