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),
    the connection is not established and an error is returned.
.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.
# * 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
$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] }

        # 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' }
    }
}

# Decide validation outcome from status code AND envelope shape.
$keyValid = switch ($true) {
    $probeSucceeded                                              { $true; break }   # 200 OK
    ($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."
}

if (-not $keyValid) {
    $msg = switch ($true) {
        ($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
}