From 0ab3ff6b0f4d751d893bf9bdb530a29414abd4d1 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:54:44 +0100 Subject: [PATCH 01/27] feat: add OAuth client-credentials login and v1 transport alongside API keys Connect-Inforcer gains a ClientCredentials parameter set (-ClientId/-ClientSecret). The API version follows the login: API key -> /beta, client credentials -> /v1. v1 responses are mapped to the module's existing field names at the transport boundary, so API-key users see no change apart from a legacy-API warning. - Get-InforcerAuthHeader is the single place auth headers are built; v1 tokens are requested and renewed automatically - Invoke-InforcerApiRequest: version-neutral endpoints, v1 renames, problem+json errors, 429 Retry-After handling, 401 token renewal, collection unwrap - New Invoke-InforcerPagedRequest, Get-InforcerTenantList (session-bound cache) - Resolve-InforcerTenantId accepts Microsoft tenant IDs and names on v1 - Test-InforcerConnection no longer writes part of the API key to Verbose Co-Authored-By: Claude Opus 5.5 --- Tests/Consistency.Tests.ps1 | 4 +- Tests/V1.Transport.Tests.ps1 | 661 ++++++++++++++++++ module/InforcerCommunity.psm1 | 2 +- .../ConvertFrom-InforcerV1Response.ps1 | 187 +++++ module/Private/Get-InforcerAuthHeader.ps1 | 130 ++++ module/Private/Get-InforcerBaseUrl.ps1 | 68 +- module/Private/Get-InforcerTenantList.ps1 | 33 + module/Private/Invoke-InforcerApiRequest.ps1 | 351 ++++++---- .../Private/Invoke-InforcerPagedRequest.ps1 | 104 +++ .../Private/Protect-InforcerApiKeyInText.ps1 | 41 +- module/Private/Resolve-InforcerTenantId.ps1 | 81 ++- module/Private/Test-InforcerSession.ps1 | 23 +- module/Public/Connect-Inforcer.ps1 | 448 +++++++----- module/Public/Disconnect-Inforcer.ps1 | 4 +- module/Public/Test-InforcerConnection.ps1 | 35 +- 15 files changed, 1743 insertions(+), 429 deletions(-) create mode 100644 Tests/V1.Transport.Tests.ps1 create mode 100644 module/Private/ConvertFrom-InforcerV1Response.ps1 create mode 100644 module/Private/Get-InforcerAuthHeader.ps1 create mode 100644 module/Private/Get-InforcerTenantList.ps1 create mode 100644 module/Private/Invoke-InforcerPagedRequest.ps1 diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index 683191b..c7dd21c 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -41,7 +41,7 @@ Describe 'Consistency contract' { 'Get-InforcerReportType', 'Invoke-InforcerReport', 'Get-InforcerReportRun', 'Save-InforcerReportOutput' ) $script:expectedParameters = @{ - 'Connect-Inforcer' = @('ApiKey', 'Region', 'BaseUrl', 'FetchGraphData', 'PassThru') + 'Connect-Inforcer' = @('ApiKey', 'ClientId', 'ClientSecret', 'Region', 'BaseUrl', 'FetchGraphData', 'PassThru') 'Disconnect-Inforcer' = @() 'Test-InforcerConnection' = @() 'Get-InforcerTenant' = @('Format', 'TenantId', 'OutputType') @@ -1249,7 +1249,7 @@ Describe 'Private helpers (via module scope)' { ) Mock -ModuleName InforcerCommunity Test-InforcerSession { $true } Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { - if ($Endpoint -eq '/beta/tenants') { + if (($Endpoint -replace '^/beta', '') -eq '/tenants') { @([PSCustomObject]@{ clientTenantId = 1; tenantFriendlyName = 'Contoso' }, [PSCustomObject]@{ clientTenantId = 2; tenantFriendlyName = 'Fabrikam' }) } else { @([PSCustomObject]@{ id = 'a1'; name = 'Test Assessment' }) diff --git a/Tests/V1.Transport.Tests.ps1 b/Tests/V1.Transport.Tests.ps1 new file mode 100644 index 0000000..95489de --- /dev/null +++ b/Tests/V1.Transport.Tests.ps1 @@ -0,0 +1,661 @@ +# V1.Transport.Tests.ps1 +# Session, auth header, URL building, v1 mapping, unwrap, errors, retries, paging and tenant resolution. +# Fixtures are anonymised (zero GUIDs, example names); no network calls. + +BeforeAll { + Remove-Module -Name 'InforcerCommunity' -ErrorAction SilentlyContinue + Import-Module (Join-Path $PSScriptRoot '../module/InforcerCommunity.psd1') -Force + $script:M = Get-Module InforcerCommunity + + $script:Tid1 = '00000000-0000-0000-0000-000000000001' + $script:Tid2 = '00000000-0000-0000-0000-000000000002' + + # Shape returned by the module's Invoke-InforcerHttpRequest wrapper (Invoke-RestMethod -SkipHttpErrorCheck). + function global:New-V1TestResponse { + param([int]$Status = 200, $Body, [string]$RetryAfter) + if ($Body -is [string]) { $Body = $Body | ConvertFrom-Json -Depth 100 } + $h = @{} + if ($RetryAfter) { $h['Retry-After'] = @($RetryAfter) } + [pscustomobject]@{ StatusCode = $Status; Body = $Body; Headers = $h } + } + + function global:Set-V1TestSession { + param([switch]$Beta, [switch]$ExpiredToken) + & (Get-Module InforcerCommunity) { + param($Beta, $ExpiredToken) + $script:InforcerTenantListCache = $null + if ($Beta) { + $script:InforcerSession = @{ + AuthMode = 'ApiKey'; ApiVersion = 'beta'; ApiHost = 'https://api.example.invalid' + BaseUrl = 'https://api.example.invalid/api'; Region = 'uk'; ConnectedAt = Get-Date + ApiKey = (ConvertTo-SecureString 'beta-test-key-value' -AsPlainText -Force) + } + } else { + $script:InforcerSession = @{ + AuthMode = 'ClientCredentials'; ApiVersion = 'v1'; ApiHost = 'https://api.example.invalid' + BaseUrl = 'https://api.example.invalid'; Region = 'uk'; ConnectedAt = Get-Date + ClientId = 'client-id-example' + ClientSecret = (ConvertTo-SecureString 'client-secret-example-value' -AsPlainText -Force) + AccessToken = (ConvertTo-SecureString 'access-token-example-value' -AsPlainText -Force) + TokenExpiresAt = if ($ExpiredToken) { [datetime]::UtcNow.AddMinutes(2) } else { [datetime]::UtcNow.AddMinutes(50) } + } + } + } $Beta.IsPresent $ExpiredToken.IsPresent + } +} + +AfterAll { + Remove-Item function:global:New-V1TestResponse -ErrorAction SilentlyContinue + Remove-Item function:global:Set-V1TestSession -ErrorAction SilentlyContinue + & (Get-Module InforcerCommunity) { $script:InforcerSession = $null; $script:InforcerTenantListCache = $null } +} + +Describe 'Test-InforcerSession' { + It 'is true for a client-credentials session' { + Set-V1TestSession + & $script:M { Test-InforcerSession } | Should -BeTrue + } + It 'is false for a v1 session without a client secret' { + & $script:M { Test-InforcerSession -Session @{ ApiVersion = 'v1'; ApiHost = 'https://api.example.invalid' } } | Should -BeFalse + } + It 'is true for a legacy session shape without ApiVersion' { + & $script:M { Test-InforcerSession -Session @{ ApiKey = (ConvertTo-SecureString 'k' -AsPlainText -Force); BaseUrl = 'https://api.example.invalid/api' } } | Should -BeTrue + } +} + +Describe 'Get-InforcerBaseUrl' { + It 'maps a region to the API host without a path' { + & $script:M { Get-InforcerBaseUrl -Region uk } | Should -Be 'https://api-uk.inforcer.com' + } + It 'accepts -BaseUrl as host+/api and as host' { + & $script:M { Get-InforcerBaseUrl -BaseUrl 'https://api.example.invalid/api/' } | Should -Be 'https://api.example.invalid' + & $script:M { Get-InforcerBaseUrl -BaseUrl 'https://api.example.invalid' } | Should -Be 'https://api.example.invalid' + } + It 'builds beta URIs from neutral and /beta-prefixed endpoints' { + Set-V1TestSession -Beta + & $script:M { Get-InforcerBaseUrl -Endpoint '/tenants' } | Should -Be 'https://api.example.invalid/api/beta/tenants' + & $script:M { Get-InforcerBaseUrl -Endpoint '/beta/tenants' } | Should -Be 'https://api.example.invalid/api/beta/tenants' + } + It 'does not apply v1 renames on beta' { + Set-V1TestSession -Beta + & $script:M { Get-InforcerBaseUrl -Endpoint '/tenants/5/roles' } | Should -Be 'https://api.example.invalid/api/beta/tenants/5/roles' + } + It 'builds v1 URIs and strips a /beta prefix' { + Set-V1TestSession + & $script:M { Get-InforcerBaseUrl -Endpoint 'tenants' } | Should -Be 'https://api.example.invalid/v1/tenants' + & $script:M { Get-InforcerBaseUrl -Endpoint '/beta/baselines' } | Should -Be 'https://api.example.invalid/v1/baselines' + } + It 'renames roles to directoryRoles on v1' { + Set-V1TestSession + & $script:M { param($t) Get-InforcerBaseUrl -Endpoint "/beta/tenants/$t/roles" } $script:Tid1 | Should -Be "https://api.example.invalid/v1/tenants/$script:Tid1/directoryRoles" + } + It 'renames auditEvents/eventTypes to auditEvents/types on v1' { + Set-V1TestSession + & $script:M { Get-InforcerBaseUrl -Endpoint '/auditEvents/eventTypes' } | Should -Be 'https://api.example.invalid/v1/auditEvents/types' + } + It 'renames the customBaselineId query parameter to baselineId on v1' { + Set-V1TestSession + & $script:M { Get-InforcerBaseUrl -Endpoint '/tenants/x/alignmentDetails?customBaselineId=abc' } | Should -Be 'https://api.example.invalid/v1/tenants/x/alignmentDetails?baselineId=abc' + } +} + +Describe 'Get-InforcerAuthHeader' { + It 'builds only Inf-Api-Key for an API-key session' { + Set-V1TestSession -Beta + $h = & $script:M { Get-InforcerAuthHeader } + $h.Keys | Should -Be @('Inf-Api-Key') + $h['Inf-Api-Key'] | Should -Be 'beta-test-key-value' + } + It 'uses the cached token while it is valid' { + Set-V1TestSession + Mock -ModuleName InforcerCommunity Invoke-RestMethod { throw 'should not be called' } + $h = & $script:M { Get-InforcerAuthHeader } + $h['Authorization'] | Should -BeExactly 'Bearer access-token-example-value' + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 0 -Exactly + } + It 'renews the token when less than five minutes remain and stores it in the session' { + Set-V1TestSession -ExpiredToken + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ access_token = 'renewed-token'; token_type = 'Bearer'; expires_in = 3600 } } + $h = & $script:M { Get-InforcerAuthHeader } + $h['Authorization'] | Should -BeExactly 'Bearer renewed-token' + $exp = & $script:M { $script:InforcerSession.TokenExpiresAt } + ($exp - [datetime]::UtcNow).TotalMinutes | Should -BeGreaterThan 55 + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://api.example.invalid/oauth/token' -and $Method -eq 'POST' -and + $ContentType -eq 'application/x-www-form-urlencoded' -and $Body.grant_type -eq 'client_credentials' -and + $Body.client_id -eq 'client-id-example' -and $Body.client_secret -eq 'client-secret-example-value' + } + } + It 'renews on -ForceRefresh' { + Set-V1TestSession + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ access_token = 'forced-token'; expires_in = 3600 } } + (& $script:M { Get-InforcerAuthHeader -ForceRefresh })['Authorization'] | Should -BeExactly 'Bearer forced-token' + } + It 'reports a failed token request without the secret' { + Set-V1TestSession -ExpiredToken + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { + New-V1TestResponse -Status 401 -Body '{"error":"invalid_client","error_description":"bad client-secret-example-value"}' + } + $e = & $script:M { $h = Get-InforcerAuthHeader -ErrorAction SilentlyContinue -ErrorVariable ev; $h; $ev } + @($e).Count | Should -Be 1 + $e[0].FullyQualifiedErrorId | Should -Match '^TokenRequestFailed' + $msg = $e[0].Exception.Message + $msg | Should -Match 'HTTP 401' + $msg | Should -Match 'invalid_client' + $msg | Should -Not -Match 'client-secret-example-value' + } +} + +Describe 'Protect-InforcerApiKeyInText' { + It 'redacts the session secret, access token and bearer tokens' { + Set-V1TestSession + $t = & $script:M { Protect-InforcerApiKeyInText -Text 'a client-secret-example-value b access-token-example-value c Authorization: Bearer abc.def-ghi' } + $t | Should -Not -Match 'client-secret-example-value|access-token-example-value|abc\.def-ghi' + $t | Should -Match 'Bearer \[REDACTED\]' + } + It 'redacts JWT-shaped strings and an explicit -ApiKey' { + $t = & $script:M { Protect-InforcerApiKeyInText -Text 'x eyJhbGciOi.eyJzdWIiOi.c2lnbmF0dXJl y explicit-key' -ApiKey 'explicit-key' -Session @{} } + $t | Should -BeExactly 'x [REDACTED] y [REDACTED]' + } +} + +Describe 'ConvertFrom-InforcerV1Response' { + BeforeAll { + function script:Map([string]$Json, [string]$Type) { + $o = $Json | ConvertFrom-Json -Depth 100 + & (Get-Module InforcerCommunity) { param($o, $t) ConvertFrom-InforcerV1Response -InputObject $o -ObjectType $t } $o $Type + } + function script:Names($o) { @($o.PSObject.Properties.Name) } + } + + It 'Tenant: name/dnsName to tenantFriendlyName/tenantDnsName, alignment summary timestamp renamed' { + $r = Map '{"msTenantId":"00000000-0000-0000-0000-000000000001","name":"Contoso","dnsName":"contoso.onmicrosoft.com","authenticationMode":"x","alignmentSummaries":[{"alignedBaselineId":"b","alignedBaselineMsTenantId":"00000000-0000-0000-0000-000000000002","lastAlignmentTimestamp":"2026-01-01T00:00:00Z"}]}' Tenant + Names $r | Should -Be @('msTenantId', 'tenantFriendlyName', 'tenantDnsName', 'authenticationMode', 'alignmentSummaries') + $r.tenantFriendlyName | Should -BeExactly 'Contoso' + Names $r.alignmentSummaries[0] | Should -Contain 'lastAlignmentDateTime' + Names $r.alignmentSummaries[0] | Should -Not -Contain 'lastAlignmentTimestamp' + } + It 'Policy: policyTypeName to inforcerPolicyTypeName, id kept' { + $r = Map '{"id":"p1","policyTypeId":1,"policyTypeName":"Settings Catalog","displayName":"P","policyData":{"id":"p1"}}' Policy + Names $r | Should -Be @('id', 'policyTypeId', 'inforcerPolicyTypeName', 'displayName', 'policyData') + } + It 'Baseline: baselineTenant flattened, members and items renamed' { + $r = Map '{"id":"b1","name":"Base","baselineTenant":{"msTenantId":"00000000-0000-0000-0000-000000000001","name":"Contoso","dnsName":"contoso.onmicrosoft.com"},"mode":"include","members":[{"msTenantId":"00000000-0000-0000-0000-000000000002","name":"Fabrikam","dnsName":"fabrikam.onmicrosoft.com","lastAlignmentScore":90}],"items":[{"product":"Intune","primaryGroup":"Windows","secondaryGroup":"Settings","alignAssignments":true}]}' Baseline + Names $r | Should -Be @('id', 'name', 'baselineMsTenantId', 'baselineTenantFriendlyName', 'baselineTenantDnsName', 'mode', 'members', 'items') + Names $r.members[0] | Should -Be @('msTenantId', 'tenantFriendlyName', 'tenantDnsName', 'lastAlignmentScore') + Names $r.items[0] | Should -Be @('policyCategoryProduct', 'policyCategoryPrimaryGroup', 'policyCategorySecondaryGroup', 'alignAssignments') + } + It 'Baseline: a null baselineTenant still yields the canonical keys' { + $r = Map '{"id":"b1","baselineTenant":null}' Baseline + Names $r | Should -Be @('id', 'baselineMsTenantId', 'baselineTenantFriendlyName', 'baselineTenantDnsName') + } + It 'AlignmentScore: every renamed field maps to the beta name' { + $r = Map '{"msTenantId":"00000000-0000-0000-0000-000000000001","tenantName":"Contoso","alignedBaselineId":"b","alignedBaselineMsTenantId":"00000000-0000-0000-0000-000000000002","alignedBaselineName":"Base","alignmentScore":97.5,"alignedThreshold":90,"semiAlignedThreshold":70,"lastAlignmentTimestamp":"t"}' AlignmentScore + Names $r | Should -Be @('msTenantId', 'tenantFriendlyName', 'baselineGroupId', 'alignedBaselineMsTenantId', 'baselineGroupName', 'score', 'alignedThreshold', 'semiAlignedThreshold', 'lastComparisonDateTime') + } + It 'AlignmentDetails: buckets, metrics and per-policy fields map to the beta shape' { + $json = '{"completedAt":"c","baseline":{"id":"b","msTenantId":"00000000-0000-0000-0000-000000000002"},"baselineDataTimestamp":"bt","tenantDataTimestamp":"tt","alignmentScore":98,"metrics":{"totalPolicies":3,"matched":1,"matchedWithAcceptedDeviations":0,"deviated":1,"missingFromTenant":1,"additionalInTenant":0},"policies":{"matched":[{"id":"p","policyTypeName":"T","displayName":"Policy A","product":"Intune","tags":[{"name":"x"}],"tenantPolicies":[{"id":"tp","policyDataDiff":[{"op":"replace","path":"/a","baselineValue":"1","tenantValue":"2"}],"tags":[]}],"variables":[{"name":"v","tenantValue":"t"}],"acceptedDeviations":[{"acceptedTimestamp":"a","reason":"r"}],"similarPolicies":[{"id":"s","name":"Sim","categoryName":"Cat"}]}],"matchedWithAcceptedDeviations":[],"deviated":[],"missingFromTenant":[],"additionalInTenant":[]}}' + $r = Map $json AlignmentDetails + Names $r | Should -Be @('completedAt', 'baseline', 'alignment', 'metrics') + Names $r.alignment | Should -Be @('alignmentScore', 'baselineDataTimestamp', 'subjectDataTimestamp', 'matchedPolicies', 'matchedWithAcceptedDeviations', 'deviatedUnaccepted', 'missingFromSubjectUnaccepted', 'additionalInSubjectUnaccepted') + Names $r.metrics | Should -Be @('totalPolicies', 'matchedPolicies', 'matchedWithAcceptedDeviations', 'deviatedPolicies', 'recommendedPoliciesFromBaseline', 'customerOnlyPolicies') + $p = $r.alignment.matchedPolicies[0] + $p.policyName | Should -BeExactly 'Policy A' + $p.inforcerPolicyTypeName | Should -BeExactly 'T' + Names $p | Should -Contain 'policyTags' + $p.matchingSubjectPolicies[0].policyDataDiff[0].subjectValue | Should -Be '2' + $p.matchingVariables[0].variableName | Should -Be 'v' + $p.acceptedDeviations[0].acceptedReason | Should -Be 'r' + $p.similarPolicies[0].policyCategoryName | Should -Be 'Cat' + $r.alignment.deviatedUnaccepted.Count | Should -Be 0 + } + It 'User: groupCount/roleCount to groups/roles' { + $r = Map '{"id":"u","displayName":"User","groupCount":3,"roleCount":1}' User + Names $r | Should -Be @('id', 'displayName', 'groups', 'roles') + } + It 'User detail: arrays named groups/roles stay as they are' { + $r = Map '{"id":"u","groups":[{"id":"g"}],"roles":[]}' User + $r.groups[0].id | Should -Be 'g' + } + It 'AssessmentRun: assessmentId to assessment' { + $r = Map '{"assessmentId":"a","name":"N","results":[]}' AssessmentRun + Names $r | Should -Be @('assessment', 'name', 'results') + } + It 'ReportRun: reportRunId to runId' { + Names (Map '{"reportRunId":"r","status":"queued"}' ReportRun) | Should -Be @('runId', 'status') + } + It 'ReportOutput: msTenantId to tenantId (null stays null for collated output)' { + $r = Map '{"id":"o","reportType":"X","msTenantId":null,"format":"csv","sizeBytes":4}' ReportOutput + Names $r | Should -Be @('id', 'reportType', 'tenantId', 'format', 'sizeBytes') + $r.tenantId | Should -BeNullOrEmpty + } + It ' has no renames and passes through unchanged' -ForEach @( + @{ Type = 'Group'; Json = '{"id":"g","displayName":"G","membershipRule":null}' } + @{ Type = 'GroupMember'; Json = '{"id":"m","displayName":"M","type":"#microsoft.graph.user"}' } + @{ Type = 'Role'; Json = '{"id":"r","templateId":"t","displayName":"R","isBuiltIn":true}' } + @{ Type = 'SecureScore'; Json = '{"currentScore":1,"maxScore":2,"scores":[]}' } + @{ Type = 'Assessment'; Json = '{"id":"a","name":"A","assessmentType":"platform"}' } + @{ Type = 'ReportType'; Json = '{"key":"K","name":"N","singleTenantOnly":false}' } + @{ Type = 'AuditEvent'; Json = '{"id":"e","eventType":"x","timestamp":"t","metadata":{}}' } + @{ Type = 'AuditEventType'; Json = '{"name":"tenantCreate"}' } + ) { + $src = $Json | ConvertFrom-Json -Depth 100 + $r = Map $Json $Type + ($r | ConvertTo-Json -Depth 100 -Compress) | Should -BeExactly ($src | ConvertTo-Json -Depth 100 -Compress) + } + It 'never emits duplicate keys when the canonical name is already present' { + $r = Map '{"name":"v1","tenantFriendlyName":"canonical","msTenantId":"00000000-0000-0000-0000-000000000001"}' Tenant + Names $r | Should -Be @('tenantFriendlyName', 'msTenantId') + $r.tenantFriendlyName | Should -BeExactly 'canonical' + } + It 'does not modify its input' { + $o = '{"name":"Contoso"}' | ConvertFrom-Json + $null = & $script:M { param($o) ConvertFrom-InforcerV1Response -InputObject $o -ObjectType Tenant } $o + Names $o | Should -Be @('name') + } +} + +Describe 'Invoke-InforcerApiRequest on v1' { + BeforeEach { Set-V1TestSession } + + It 'unwraps the collection, maps it and sends a bearer header to the v1 URI' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ tenants = @([pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000001'; name = 'Contoso'; dnsName = 'contoso.example' }) } + } + $r = @(& $script:M { Invoke-InforcerApiRequest -Endpoint '/beta/tenants' -ObjectType Tenant }) + $r.Count | Should -Be 1 + $r[0].tenantFriendlyName | Should -BeExactly 'Contoso' + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://api.example.invalid/v1/tenants' -and $Headers['Authorization'] -eq 'Bearer access-token-example-value' -and -not $Headers.ContainsKey('Inf-Api-Key') + } + } + It 'never unwraps a single object that holds arrays' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000001'; name = 'Contoso'; tags = @(); licenses = @([pscustomobject]@{ sku = 'X' }) } + } + $r = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants/00000000-0000-0000-0000-000000000001' -ObjectType Tenant } + $r.tenantFriendlyName | Should -BeExactly 'Contoso' + $r.licenses[0].sku | Should -Be 'X' + } + It 'unwraps a collection next to metadata keys and ignores the metadata' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ users = @([pscustomobject]@{ id = 'u1'; groupCount = 2 }); userCount = 1; continuationToken = $null } + } + $r = @(& $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants/x/users' -ObjectType User }) + $r.Count | Should -Be 1 + $r[0].groups | Should -Be 2 + } + It '-OutputType JsonObject returns a JSON array, not the wrapper, even for one item' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ reportRuns = @([pscustomobject]@{ reportRunId = 'r1' }) } } + $j = & $script:M { Invoke-InforcerApiRequest -Endpoint '/reports/runs' -ObjectType ReportRun -OutputType JsonObject } + $j.TrimStart() | Should -Match '^\[' + $j | Should -Match '"runId"' + $j | Should -Not -Match 'reportRuns|reportRunId' + } + It '-OutputType JsonObject of an empty collection is []' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ policies = @(); failedPolicyTypes = [pscustomobject]@{} } } + (& $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants/x/policies' -OutputType JsonObject }) | Should -Be '[]' + } + It 'warns with the type names when failedPolicyTypes is not empty' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ policies = @(); failedPolicyTypes = [pscustomobject]@{ 'Device Configuration' = 'timeout'; 'Compliance' = 'error' } } + } + $w = & $script:M { $null = Invoke-InforcerApiRequest -Endpoint '/tenants/x/policies' -WarningVariable wv -WarningAction SilentlyContinue; $wv } + ($w -join ' ') | Should -Match 'Device Configuration' + ($w -join ' ') | Should -Match 'Compliance' + } + It 'stays quiet when failedPolicyTypes is empty' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ policies = @(); failedPolicyTypes = [pscustomobject]@{} } } + $w = & $script:M { $null = Invoke-InforcerApiRequest -Endpoint '/tenants/x/policies' -WarningVariable wv -WarningAction SilentlyContinue; $wv } + $w | Should -BeNullOrEmpty + } + It '-PreserveFullResponse keeps continuationToken and maps the collection' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ groups = @([pscustomobject]@{ id = 'g1' }); groupCount = 9; continuationToken = 'next' } + } + $r = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants/x/groups' -ObjectType Group -PreserveFullResponse } + $r.continuationToken | Should -Be 'next' + $r.groupCount | Should -Be 9 + @($r.groups).Count | Should -Be 1 + } + It 'turns problem+json into "title: detail (field: message)" with ApiRequestFailed_' { + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { + New-V1TestResponse -Status 400 -Body '{"type":"about:blank","title":"Validation failed","status":400,"detail":"The request is invalid","instance":"/v1/x","errors":{"baselineId":["is required","must be a uuid"]}}' + } + $e = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants/x/alignmentDetails' -ErrorAction SilentlyContinue -ErrorVariable ev; $ev } + $e[0].FullyQualifiedErrorId | Should -Match '^ApiRequestFailed_400' + $e[0].Exception.Message | Should -Match 'Validation failed: The request is invalid \(baselineId: is required, must be a uuid\)' + } + It 'keeps 404 messages recognisable for callers matching 404|not found' { + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { + New-V1TestResponse -Status 404 -Body '{"type":"about:blank","title":"Not Found","status":404,"detail":"Tenant not found"}' + } + $e = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants/x' -ErrorAction SilentlyContinue -ErrorVariable ev; $ev } + $e[0].FullyQualifiedErrorId | Should -Match '^ApiRequestFailed_404' + $e[0].Exception.Message | Should -Match '404|not found' + @($e).Count | Should -Be 1 -Because 'callers check $err[0].FullyQualifiedErrorId, so no raw web error may precede it' + } + It 'reports a failed token renewal once as TokenRequestFailed' { + Set-V1TestSession -ExpiredToken + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { New-V1TestResponse -Status 401 -Body '{"error":"invalid_client"}' } + $e = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants' -ErrorAction SilentlyContinue -ErrorVariable ev; $ev } + @($e).Count | Should -Be 1 + $e[0].FullyQualifiedErrorId | Should -Match '^TokenRequestFailed' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly + } + It 'honours Retry-After seconds on 429 and then succeeds' { + $script:calls = 0 + Mock -ModuleName InforcerCommunity Start-Sleep { } + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { + $script:calls++ + if ($script:calls -eq 1) { return (New-V1TestResponse -Status 429 -RetryAfter '7' -Body '{"title":"Too Many Requests","status":429}') } + New-V1TestResponse -Body '{"tenants":[{"msTenantId":"00000000-0000-0000-0000-000000000001"}]}' + } + $r = @(& $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants' }) + $r.Count | Should -Be 1 + Should -Invoke -ModuleName InforcerCommunity Start-Sleep -Times 1 -Exactly -ParameterFilter { $Seconds -eq 7 } + } + It 'honours a Retry-After HTTP date' { + $s = & $script:M { Get-InforcerRetryDelay -Value ([datetimeoffset]::UtcNow.AddSeconds(30).ToString('r')) } + $s | Should -BeGreaterOrEqual 28 + $s | Should -BeLessOrEqual 31 + } + It 'gives up after three 429 retries with ApiRequestFailed_429' { + Mock -ModuleName InforcerCommunity Start-Sleep { } + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { New-V1TestResponse -Status 429 -RetryAfter '1' } + $e = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants' -ErrorAction SilentlyContinue -ErrorVariable ev; $ev } + @($e).Count | Should -Be 1 -Because 'only the module error reaches -ErrorVariable' + $e[0].FullyQualifiedErrorId | Should -Match '^ApiRequestFailed_429' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 4 -Exactly + Should -Invoke -ModuleName InforcerCommunity Start-Sleep -Times 3 -Exactly + } + It 'renews the token once on 401 and retries' { + $script:calls = 0 + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { + if ($Request.Uri -like '*/oauth/token') { return (New-V1TestResponse -Body '{"access_token":"renewed-after-401","expires_in":3600}') } + $script:calls++ + if ($script:calls -eq 1) { return (New-V1TestResponse -Status 401) } + New-V1TestResponse -Body '{"tenants":[]}' + } + $e = & $script:M { $null = Invoke-InforcerApiRequest -Endpoint '/tenants' -ErrorAction SilentlyContinue -ErrorVariable ev; $ev } + $e | Should -BeNullOrEmpty + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly -ParameterFilter { $Request.Uri -like '*/oauth/token' } + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly -ParameterFilter { $Request.Headers['Authorization'] -eq 'Bearer renewed-after-401' } + } + It 'renews only once when a 401 is followed by a 429' { + $script:calls = 0 + Mock -ModuleName InforcerCommunity Start-Sleep { } + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { + if ($Request.Uri -like '*/oauth/token') { return (New-V1TestResponse -Body '{"access_token":"renewed","expires_in":3600}') } + $script:calls++ + switch ($script:calls) { + 1 { New-V1TestResponse -Status 401 } + 2 { New-V1TestResponse -Status 429 -RetryAfter '1' } + default { New-V1TestResponse -Body '{"tenants":[]}' } + } + } + $null = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants' } + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly -ParameterFilter { $Request.Uri -like '*/oauth/token' } + } + It 'reports a second 401 instead of looping' { + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { + if ($Request.Uri -like '*/oauth/token') { return (New-V1TestResponse -Body '{"access_token":"renewed","expires_in":3600}') } + New-V1TestResponse -Status 401 -Body '{"title":"Unauthorized","status":401}' + } + $e = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants' -ErrorAction SilentlyContinue -ErrorVariable ev; $ev } + $e[0].FullyQualifiedErrorId | Should -Match '^ApiRequestFailed_401' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly -ParameterFilter { $Request.Uri -like '*/oauth/token' } + } + It 'never puts the token in an error message' { + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { New-V1TestResponse -Status 500 -Body '{"title":"Boom","status":500,"detail":"echo access-token-example-value"}' } + $e = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants' -ErrorAction SilentlyContinue -ErrorVariable ev; $ev } + $e[0].Exception.Message | Should -Not -Match 'access-token-example-value' + } +} + +Describe 'Invoke-InforcerApiRequest on beta (unchanged behaviour)' { + BeforeEach { Set-V1TestSession -Beta } + + It 'unwraps .data, sends Inf-Api-Key and accepts neutral and /beta paths' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ success = $true; data = @([pscustomobject]@{ clientTenantId = 1 }) } } + @(& $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants' }).Count | Should -Be 1 + @(& $script:M { Invoke-InforcerApiRequest -Endpoint '/beta/tenants' -ObjectType Tenant }).Count | Should -Be 1 + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 2 -Exactly -ParameterFilter { + $Uri -eq 'https://api.example.invalid/api/beta/tenants' -and $Headers['Inf-Api-Key'] -eq 'beta-test-key-value' -and -not $Headers.ContainsKey('Authorization') + } + } + It 'does not retry a beta 401' { + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { New-V1TestResponse -Status 401 -Body '{"statusCode":401,"message":"Access denied"}' } + $e = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants' -ErrorAction SilentlyContinue -ErrorVariable ev; $ev } + $e[0].FullyQualifiedErrorId | Should -Match '^ApiRequestFailed_401' + $e[0].Exception.Message | Should -Match 'Access denied' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly + } + It 'keeps the app-envelope errors[] detail' { + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { New-V1TestResponse -Status 403 -Body '{"success":false,"errorCode":"forbidden","message":"Forbidden","errors":["Insufficient scope"]}' } + $e = & $script:M { Invoke-InforcerApiRequest -Endpoint '/tenants' -ErrorAction SilentlyContinue -ErrorVariable ev; $ev } + $e[0].Exception.Message | Should -Match 'HTTP 403.*Insufficient scope' + } +} + +Describe 'Invoke-InforcerPagedRequest' { + It 'follows continuationToken in the query string on v1 GET' { + Set-V1TestSession + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + switch -Wildcard ($Uri) { + '*continuationToken=t2' { [pscustomobject]@{ users = @([pscustomobject]@{ id = 'u3' }); continuationToken = $null } } + '*continuationToken=t%2B1' { [pscustomobject]@{ users = @([pscustomobject]@{ id = 'u2' }); continuationToken = 't2' } } + default { [pscustomobject]@{ users = @([pscustomobject]@{ id = 'u1'; groupCount = 1 }); userCount = 3; continuationToken = 't+1' } } + } + } + $r = @(& $script:M { Invoke-InforcerPagedRequest -Endpoint '/tenants/x/users?search=a' -ObjectType User }) + $r.id | Should -Be @('u1', 'u2', 'u3') + $r[0].groups | Should -Be 1 + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://api.example.invalid/v1/tenants/x/users?search=a&continuationToken=t%2B1' } + } + It 'stops when the API repeats a token' { + Set-V1TestSession + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ groups = @([pscustomobject]@{ id = 'g' }); continuationToken = 'same' } } + $r = @(& $script:M { Invoke-InforcerPagedRequest -Endpoint '/tenants/x/groups' }) + $r.Count | Should -Be 2 + } + It 'stops at -MaxResults' { + Set-V1TestSession + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ members = @(1..100 | ForEach-Object { [pscustomobject]@{ id = "m$_" } }); continuationToken = [guid]::NewGuid().ToString() } + } + $r = @(& $script:M { Invoke-InforcerPagedRequest -Endpoint '/tenants/x/groups/g/members' -MaxResults 150 }) + $r.Count | Should -Be 150 + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 2 -Exactly + } + It 'passes continuationToken in the POST body on v1 audit search' { + Set-V1TestSession + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + $b = $Body | ConvertFrom-Json + if ($b.continuationToken -eq 'c1') { [pscustomobject]@{ auditEvents = @([pscustomobject]@{ id = 'e2' }); continuationToken = $null } } + else { [pscustomobject]@{ auditEvents = @([pscustomobject]@{ id = 'e1' }); continuationToken = 'c1' } } + } + $r = @(& $script:M { Invoke-InforcerPagedRequest -Endpoint '/auditEvents/search' -Method POST -Body @{ pageSize = 1; eventTypes = @('x') } -ObjectType AuditEvent }) + $r.id | Should -Be @('e1', 'e2') + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 2 -Exactly -ParameterFilter { $Method -eq 'POST' -and ($Body | ConvertFrom-Json).pageSize -eq 1 } + } + It 'pages beta lists with the token at the response root' { + Set-V1TestSession -Beta + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + if ($Uri -like '*continuationToken=b2') { [pscustomobject]@{ data = @([pscustomobject]@{ id = 'u2' }); continuationToken = $null; totalCount = 2 } } + else { [pscustomobject]@{ data = @([pscustomobject]@{ id = 'u1' }); continuationToken = 'b2'; totalCount = 2 } } + } + (@(& $script:M { Invoke-InforcerPagedRequest -Endpoint '/beta/tenants/1/users' })).id | Should -Be @('u1', 'u2') + } + It 'pages beta audit search with items and token inside .data' { + Set-V1TestSession -Beta + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + $b = $Body | ConvertFrom-Json + if ($b.continuationToken) { [pscustomobject]@{ success = $true; data = [pscustomobject]@{ items = @([pscustomobject]@{ id = 'e2' }); continuationToken = $null } } } + else { [pscustomobject]@{ success = $true; data = [pscustomobject]@{ items = @([pscustomobject]@{ id = 'e1' }); continuationToken = 'n' } } } + } + (@(& $script:M { Invoke-InforcerPagedRequest -Endpoint '/auditEvents/search' -Method POST -Body @{ pageSize = 1 } })).id | Should -Be @('e1', 'e2') + } +} + +Describe 'Tenant list and Resolve-InforcerTenantId' { + BeforeEach { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ tenants = @( + [pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000001'; name = 'Contoso'; dnsName = 'contoso.onmicrosoft.com' }, + [pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000002'; name = 'Fabrikam'; dnsName = 'fabrikam.onmicrosoft.com' }, + [pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000003'; name = 'Twin'; dnsName = 'twin1.onmicrosoft.com' }, + [pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000004'; name = 'Twin'; dnsName = 'twin2.onmicrosoft.com' } + ) } + } + } + + It 'caches the mapped tenant list for the session' { + Set-V1TestSession + $a = @(& $script:M { Get-InforcerTenantList }) + $b = @(& $script:M { Get-InforcerTenantList }) + $a[0].tenantFriendlyName | Should -BeExactly 'Contoso' + $b.Count | Should -Be 4 + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 1 -Exactly + } + It 'does not cache a failed tenant list request' { + Set-V1TestSession + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { New-V1TestResponse -Status 403 -Body '{"title":"Forbidden","status":403}' } + $null = & $script:M { Get-InforcerTenantList -ErrorAction SilentlyContinue } + & $script:M { $script:InforcerTenantListCache } | Should -BeNullOrEmpty + } + It 'does not serve the cache to a different session' { + Set-V1TestSession + $null = & $script:M { Get-InforcerTenantList } + & $script:M { $script:InforcerSession = $script:InforcerSession.Clone() } + $null = & $script:M { Get-InforcerTenantList } + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 2 -Exactly + } + It 'refuses a numeric id on v1 with TenantIdNumericNotSupportedOnV1' { + Set-V1TestSession + $err = $null + try { & $script:M { Resolve-InforcerTenantId -TenantId 482 } } catch { $err = $_ } + $err.FullyQualifiedErrorId | Should -Match '^TenantIdNumericNotSupportedOnV1' + $err.Exception.Message | Should -Match 'API key' + $err.Exception.Message | Should -Match 'Microsoft tenant ID' + } + It 'passes a GUID through on v1 without an API call' { + Set-V1TestSession + & $script:M { Resolve-InforcerTenantId -TenantId '00000000-0000-0000-0000-00000000000A' } | Should -BeExactly '00000000-0000-0000-0000-00000000000a' + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 0 -Exactly + } + It 'resolves a name or DNS name to msTenantId on v1' { + Set-V1TestSession + $r = & $script:M { Resolve-InforcerTenantId -TenantId 'contoso' } + $r | Should -BeExactly '00000000-0000-0000-0000-000000000001' + $r | Should -BeOfType [string] + & $script:M { Resolve-InforcerTenantId -TenantId 'fabrikam.onmicrosoft.com' } | Should -BeExactly '00000000-0000-0000-0000-000000000002' + } + It 'refuses an ambiguous name and asks for the GUID on v1' { + Set-V1TestSession + { & $script:M { Resolve-InforcerTenantId -TenantId 'Twin' } } | Should -Throw '*2 tenants match*Microsoft tenant ID*' + } + It 'resolves names from raw v1 -TenantData too' { + Set-V1TestSession + $data = @([pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000009'; name = 'Raw'; dnsName = 'raw.example' }) + & $script:M { param($d) Resolve-InforcerTenantId -TenantId 'Raw' -TenantData $d } $data | Should -BeExactly '00000000-0000-0000-0000-000000000009' + } + It 'keeps beta numeric passthrough and name resolution to [int]' { + Set-V1TestSession -Beta + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ data = @([pscustomobject]@{ clientTenantId = 7; tenantFriendlyName = 'Contoso'; msTenantId = '00000000-0000-0000-0000-000000000001' }) } } + & $script:M { Resolve-InforcerTenantId -TenantId '42' } | Should -BeOfType [int] + $r = & $script:M { Resolve-InforcerTenantId -TenantId 'Contoso' } + $r | Should -Be 7 + $r | Should -BeOfType [int] + & $script:M { Resolve-InforcerTenantId -TenantId '00000000-0000-0000-0000-000000000001' } | Should -Be 7 + } +} + +Describe 'Connect-Inforcer with client credentials' { + BeforeEach { & $script:M { $script:InforcerSession = $null; $script:InforcerReportTypeCache = $null } } + AfterEach { & $script:M { $script:InforcerSession = $null; $script:InforcerReportTypeCache = $null; $script:InforcerReportTypeCacheStamp = $null } } + + It 'connects to v1 with a token and stores only SecureStrings' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ access_token = 'connect-token-value'; token_type = 'Bearer'; expires_in = 3600 } } + Mock -ModuleName InforcerCommunity Invoke-WebRequest { [pscustomobject]@{ StatusCode = 403; Content = '{}'; Headers = @{} } } + $secret = ConvertTo-SecureString 'connect-secret-value' -AsPlainText -Force + $w = $null + $r = Connect-Inforcer -ClientId 'client-id-example' -ClientSecret $secret -Region eu -WarningVariable w + $r.Status | Should -Be 'Connected' + $r.BaseUrl | Should -Be 'https://api-eu.inforcer.com' + $w | Should -BeNullOrEmpty -Because 'the legacy warning is only for API keys' + $s = & $script:M { $script:InforcerSession } + $s.AuthMode | Should -Be 'ClientCredentials' + $s.ApiVersion | Should -Be 'v1' + $s.ApiHost | Should -Be 'https://api-eu.inforcer.com' + $s.ContainsKey('ApiKey') | Should -BeFalse + $s.ClientSecret | Should -BeOfType [securestring] + $s.AccessToken | Should -BeOfType [securestring] + $s.TokenExpiresAt.Kind | Should -Be 'Utc' + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://api-eu.inforcer.com/oauth/token' } + } + It 'accepts a plain-string secret and a host+/api -BaseUrl' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ access_token = 't'; expires_in = 3600 } } + Mock -ModuleName InforcerCommunity Invoke-WebRequest { [pscustomobject]@{ StatusCode = 403; Content = '{}'; Headers = @{} } } + $null = Connect-Inforcer -ClientId 'c' -ClientSecret 'plain-secret' -BaseUrl 'https://api.example.invalid/api' + (& $script:M { $script:InforcerSession.ApiHost }) | Should -Be 'https://api.example.invalid' + } + It 'fails cleanly on a wrong secret without echoing it and without a session' { + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { New-V1TestResponse -Status 401 -Body '{"title":"Unauthorized","status":401,"detail":"Invalid client credentials"}' } + $err = $null + $r = Connect-Inforcer -ClientId 'c' -ClientSecret 'wrong-secret-value' -ErrorAction SilentlyContinue -ErrorVariable err + $r | Should -BeNullOrEmpty + $err[0].FullyQualifiedErrorId | Should -Match '^ConnectionValidationFailed' + $err[0].Exception.Message | Should -Match 'HTTP 401' + $err[0].Exception.Message | Should -Not -Match 'wrong-secret-value' + & $script:M { $script:InforcerSession } | Should -BeNullOrEmpty + } + It '-PassThru holds no plaintext secret or token' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ access_token = 'passthru-token-value'; expires_in = 3600 } } + Mock -ModuleName InforcerCommunity Invoke-WebRequest { [pscustomobject]@{ StatusCode = 403; Content = '{}'; Headers = @{} } } + $out = Connect-Inforcer -ClientId 'c' -ClientSecret 'passthru-secret-value' -PassThru + $session = $out | Where-Object { $_ -is [hashtable] } + $session.ApiVersion | Should -Be 'v1' + $text = ($session | ConvertTo-Json -Depth 10) + ($session | Out-String) + $text | Should -Not -Match 'passthru-token-value|passthru-secret-value' + [object]::ReferenceEquals($session, (& $script:M { $script:InforcerSession })) | Should -BeFalse + } + It 'primes the report type cache from the v1 reportTypes collection' { + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ access_token = 't'; expires_in = 3600 } } + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ StatusCode = 200; Headers = @{}; Content = '{"reportTypes":[{"key":"ActiveUserCount","name":"Active User Count","collatable":false,"singleTenantOnly":false,"supportedOutputFormats":["csv"],"tags":[],"requiredParameters":[]}]}' } + } + $null = Connect-Inforcer -ClientId 'c' -ClientSecret 's' + $cache = & $script:M { $script:InforcerReportTypeCache } + @($cache).Count | Should -Be 1 + $cache[0].key | Should -Be 'ActiveUserCount' + Should -Invoke -ModuleName InforcerCommunity Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://api-uk.inforcer.com/v1/reports/types' -and $Headers['Authorization'] -eq 'Bearer t' } + } + It 'shows the legacy warning on an API-key login and builds a beta session' { + Mock -ModuleName InforcerCommunity Invoke-WebRequest { [pscustomobject]@{ StatusCode = 200; Content = '{"success":true,"data":[]}'; Headers = @{} } } + $w = $null + $r = Connect-Inforcer -ApiKey 'key-value-example' -Region uk -WarningVariable w -WarningAction SilentlyContinue + $r.BaseUrl | Should -Be 'https://api-uk.inforcer.com/api' + ($w -join ' ') | Should -Match 'legacy' + $s = & $script:M { $script:InforcerSession } + $s.AuthMode | Should -Be 'ApiKey' + $s.ApiVersion | Should -Be 'beta' + Should -Invoke -ModuleName InforcerCommunity Invoke-WebRequest -ParameterFilter { $Uri -eq 'https://api-uk.inforcer.com/api/beta/baselines' -and $Headers['Inf-Api-Key'] -eq 'key-value-example' } + } +} + +Describe 'Test-InforcerConnection and Disconnect-Inforcer on v1' { + It 'tests the v1 session through the transport and never writes key material to verbose' { + Set-V1TestSession + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ baselines = @() } } + $all = Test-InforcerConnection -Verbose 4>&1 6>$null + ($all | Where-Object { $_ -is [bool] }) | Should -BeTrue + ($all | Out-String) | Should -Not -Match 'first 8|Length|access-token-example-value|client-secret-example-value' + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://api.example.invalid/v1/baselines' } + } + It 'disconnects a v1 session' { + Set-V1TestSession + Disconnect-Inforcer | Should -Be 'Disconnected from Inforcer API.' + & $script:M { $script:InforcerSession } | Should -BeNullOrEmpty + } +} diff --git a/module/InforcerCommunity.psm1 b/module/InforcerCommunity.psm1 index d9ff1b6..61c8de7 100644 --- a/module/InforcerCommunity.psm1 +++ b/module/InforcerCommunity.psm1 @@ -13,7 +13,7 @@ $script:InforcerProgressIdSeed = 10000 # Warn users who force-reimport the module mid-session that session and caches are lost. $ExecutionContext.SessionState.Module.OnRemove = { - if ($script:InforcerSession -and $script:InforcerSession.ApiKey -and $script:InforcerSession.BaseUrl) { + if ($script:InforcerSession -and ($script:InforcerSession.ApiKey -or $script:InforcerSession.ClientSecret)) { Write-Warning 'InforcerCommunity module unloaded — active session and caches are cleared. Re-run Connect-Inforcer to continue.' } } diff --git a/module/Private/ConvertFrom-InforcerV1Response.ps1 b/module/Private/ConvertFrom-InforcerV1Response.ps1 new file mode 100644 index 0000000..42e30ee --- /dev/null +++ b/module/Private/ConvertFrom-InforcerV1Response.ps1 @@ -0,0 +1,187 @@ +$script:InforcerV1MetadataKeys = @('continuationToken', 'userCount', 'groupCount', 'memberCount', 'failedPolicyTypes') +$script:InforcerV1CollectionKeys = @('tenants', 'policies', 'users', 'groups', 'members', 'roles', 'baselines', 'alignmentScores', + 'assessments', 'reportRuns', 'outputs', 'reportTypes', 'auditEvents', 'eventTypes') + +# Rename: v1 name -> canonical (beta) name. Nested: v1 property name -> map applied to its value(s). +# Flatten: v1 object property -> its child properties lifted onto the parent under canonical names. +$script:InforcerV1Map = @{ + Tenant = @{ Rename = @{ name = 'tenantFriendlyName'; dnsName = 'tenantDnsName' }; Nested = @{ alignmentSummaries = 'AlignmentSummary' } } + AlignmentSummary = @{ Rename = @{ lastAlignmentTimestamp = 'lastAlignmentDateTime' } } + Policy = @{ Rename = @{ policyTypeName = 'inforcerPolicyTypeName' } } + Baseline = @{ + Flatten = @{ baselineTenant = [ordered]@{ msTenantId = 'baselineMsTenantId'; name = 'baselineTenantFriendlyName'; dnsName = 'baselineTenantDnsName' } } + Nested = @{ members = 'BaselineMember'; items = 'BaselineItem' } + } + BaselineMember = @{ Rename = @{ name = 'tenantFriendlyName'; dnsName = 'tenantDnsName' } } + BaselineItem = @{ Rename = @{ product = 'policyCategoryProduct'; primaryGroup = 'policyCategoryPrimaryGroup'; secondaryGroup = 'policyCategorySecondaryGroup' } } + AlignmentScore = @{ Rename = @{ tenantName = 'tenantFriendlyName'; alignedBaselineId = 'baselineGroupId'; alignedBaselineName = 'baselineGroupName'; alignmentScore = 'score'; lastAlignmentTimestamp = 'lastComparisonDateTime' } } + AlignmentMetrics = @{ Rename = @{ matched = 'matchedPolicies'; deviated = 'deviatedPolicies'; missingFromTenant = 'recommendedPoliciesFromBaseline'; additionalInTenant = 'customerOnlyPolicies' } } + ComparedPolicy = @{ + Rename = @{ displayName = 'policyName'; policyTypeName = 'inforcerPolicyTypeName'; tags = 'policyTags'; tenantPolicies = 'matchingSubjectPolicies'; variables = 'matchingVariables' } + Nested = @{ tenantPolicies = 'TenantPolicyMatch'; variables = 'Variable'; acceptedDeviations = 'AcceptedDeviation'; similarPolicies = 'SimilarPolicy' } + } + TenantPolicyMatch = @{ Rename = @{ tags = 'policyTags' }; Nested = @{ policyDataDiff = 'PolicyDiff'; policyAssignmentsDiff = 'PolicyDiff'; acceptedDeviations = 'AcceptedDeviation' } } + PolicyDiff = @{ Rename = @{ tenantValue = 'subjectValue' } } + Variable = @{ Rename = @{ name = 'variableName'; tenantValue = 'subjectValue' } } + AcceptedDeviation = @{ Rename = @{ acceptedTimestamp = 'acceptedDateTime'; reason = 'acceptedReason' }; Nested = @{ policyDataDiff = 'PolicyDiff'; policyAssignmentsDiff = 'PolicyDiff' } } + SimilarPolicy = @{ Rename = @{ name = 'policyName'; categoryName = 'policyCategoryName' }; Nested = @{ policyDataDiff = 'PolicyDiff'; policyAssignmentsDiff = 'PolicyDiff' } } + User = @{ Rename = @{ groupCount = 'groups'; roleCount = 'roles' } } + AssessmentRun = @{ Rename = @{ assessmentId = 'assessment' } } + ReportRun = @{ Rename = @{ reportRunId = 'runId' } } + ReportOutput = @{ Rename = @{ msTenantId = 'tenantId' } } +} + +$script:InforcerV1AlignmentBuckets = [ordered]@{ + matched = 'matchedPolicies' + matchedWithAcceptedDeviations = 'matchedWithAcceptedDeviations' + deviated = 'deviatedUnaccepted' + missingFromTenant = 'missingFromSubjectUnaccepted' + additionalInTenant = 'additionalInSubjectUnaccepted' +} + +function ConvertFrom-InforcerV1Response { + <# + .SYNOPSIS + Maps v1 API objects to the module's canonical (beta) property names (Private helper). + .DESCRIPTION + Pure function: returns new objects, never calls the API. Renamed v1 keys are removed (no + duplicate keys); v1-only properties are kept; property order is preserved. When a v1 object + already carries the canonical name, that value wins and the v1 duplicate is dropped. + Types without renames (Group, GroupMember, Role, SecureScore, Assessment, ReportType, + AuditEvent, AuditEventType) are returned unchanged. + .PARAMETER InputObject + One or more v1 objects (collection items, or a single-object response). + .PARAMETER ObjectType + The kind of object, selecting the mapping. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [AllowNull()] + [AllowEmptyCollection()] + [object[]]$InputObject, + + [Parameter(Mandatory = $true)] + [ValidateSet('Tenant', 'Policy', 'Baseline', 'AlignmentScore', 'AlignmentDetails', 'User', 'Group', 'GroupMember', 'Role', + 'SecureScore', 'Assessment', 'AssessmentRun', 'ReportType', 'ReportRun', 'ReportOutput', 'AuditEvent', 'AuditEventType')] + [string]$ObjectType + ) + + process { + foreach ($item in $InputObject) { + if ($null -eq $item) { continue } + if ($ObjectType -eq 'AlignmentDetails') { ConvertFrom-InforcerV1AlignmentDetail $item } + elseif ($script:InforcerV1Map.ContainsKey($ObjectType)) { ConvertFrom-InforcerV1Object $item $ObjectType } + else { $item } + } + } +} + +function ConvertFrom-InforcerV1Object { + param($Object, [string]$MapName) + + if ($Object -isnot [System.Management.Automation.PSCustomObject]) { return $Object } + $map = $script:InforcerV1Map[$MapName] + $rename = $map.Rename; $nested = $map.Nested; $flatten = $map.Flatten + + $sourceNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($p in $Object.PSObject.Properties) { [void]$sourceNames.Add($p.Name) } + + $out = [ordered]@{} + foreach ($p in $Object.PSObject.Properties) { + $value = $p.Value + if ($nested -and $nested.ContainsKey($p.Name)) { $value = ConvertFrom-InforcerV1Value $value $nested[$p.Name] } + + if ($flatten -and $flatten.ContainsKey($p.Name)) { + if ($value -is [System.Management.Automation.PSCustomObject]) { + $childMap = $flatten[$p.Name] + foreach ($c in $value.PSObject.Properties) { + $target = if ($childMap.Contains($c.Name)) { $childMap[$c.Name] } else { "$($p.Name)$($c.Name.Substring(0,1).ToUpperInvariant())$($c.Name.Substring(1))" } + if (-not $sourceNames.Contains($target) -and -not $out.Contains($target)) { $out[$target] = $c.Value } + } + } else { + foreach ($target in $flatten[$p.Name].Values) { + if (-not $sourceNames.Contains($target) -and -not $out.Contains($target)) { $out[$target] = $null } + } + } + continue + } + + $name = $p.Name + if ($rename -and $rename.ContainsKey($name)) { + $target = $rename[$name] + # The canonical name already exists on the source object: keep that one, drop the v1 duplicate. + if ($sourceNames.Contains($target) -and -not $rename.ContainsKey($target)) { continue } + $name = $target + } + if (-not $out.Contains($name)) { $out[$name] = $value } + } + [PSCustomObject]$out +} + +function ConvertFrom-InforcerV1Value { + param($Value, [string]$MapName) + + if ($null -eq $Value) { return $null } + if ($Value -is [array]) { + $list = [System.Collections.Generic.List[object]]::new($Value.Count) + foreach ($v in $Value) { $list.Add((ConvertFrom-InforcerV1Object $v $MapName)) } + return , $list.ToArray() + } + ConvertFrom-InforcerV1Object $Value $MapName +} + +function ConvertFrom-InforcerV1AlignmentDetail { + param($Object) + + if ($Object -isnot [System.Management.Automation.PSCustomObject]) { return $Object } + $consumed = @{ alignmentScore = 'alignmentScore'; baselineDataTimestamp = 'baselineDataTimestamp'; tenantDataTimestamp = 'subjectDataTimestamp'; policies = $null } + + $alignment = [ordered]@{} + foreach ($key in 'alignmentScore', 'baselineDataTimestamp', 'tenantDataTimestamp') { + $prop = $Object.PSObject.Properties[$key] + if ($prop) { $alignment[$consumed[$key]] = $prop.Value } + } + $policies = $Object.PSObject.Properties['policies'].Value + if ($policies -is [System.Management.Automation.PSCustomObject]) { + foreach ($bucket in $policies.PSObject.Properties) { + $target = if ($script:InforcerV1AlignmentBuckets.Contains($bucket.Name)) { $script:InforcerV1AlignmentBuckets[$bucket.Name] } else { $bucket.Name } + $alignment[$target] = ConvertFrom-InforcerV1Value $bucket.Value 'ComparedPolicy' + } + } + + $out = [ordered]@{} + foreach ($p in $Object.PSObject.Properties) { + if ($consumed.ContainsKey($p.Name)) { + $out['alignment'] = [PSCustomObject]$alignment + continue + } + if ($out.Contains($p.Name)) { continue } + $out[$p.Name] = $p.Value + if ($p.Name -eq 'metrics') { $out[$p.Name] = ConvertFrom-InforcerV1Value $p.Value 'AlignmentMetrics' } + } + [PSCustomObject]$out +} + +function Get-InforcerV1CollectionName { + <# + .SYNOPSIS + Returns the name of the single collection property of a v1 list response, or $null for a single object (Private helper). + .DESCRIPTION + v1 list responses are an object with exactly one collection plus optional metadata keys + (continuationToken, userCount, groupCount, memberCount, failedPolicyTypes). Single-object + responses are returned bare and never count as a collection, even when they hold arrays. + #> + param($Response) + + if ($Response -isnot [System.Management.Automation.PSCustomObject]) { return $null } + $c = $null + foreach ($p in $Response.PSObject.Properties) { + if ($p.Name -in $script:InforcerV1MetadataKeys) { continue } + if ($c) { return $null } + $c = $p + } + if (-not $c) { return $null } + if ($c.Value -is [array] -or ($null -eq $c.Value -and $c.Name -in $script:InforcerV1CollectionKeys)) { return $c.Name } + $null +} diff --git a/module/Private/Get-InforcerAuthHeader.ps1 b/module/Private/Get-InforcerAuthHeader.ps1 new file mode 100644 index 0000000..7530ec9 --- /dev/null +++ b/module/Private/Get-InforcerAuthHeader.ps1 @@ -0,0 +1,130 @@ +function Get-InforcerAuthHeader { + <# + .SYNOPSIS + Returns the authentication header for an Inforcer session (Private helper). + .DESCRIPTION + The only place the Inforcer auth header is built. + API-key session (beta): @{ 'Inf-Api-Key' = } + Client-credentials session (v1): @{ Authorization = 'Bearer ' } + For v1 the access token is requested when missing and renewed when less than five minutes + remain; the new token and expiry are written back into the session hashtable. + On failure it writes one error (NotConnected, EmptyApiKey or TokenRequestFailed) and returns + nothing, so callers do: $h = Get-InforcerAuthHeader; if (-not $h) { return } + .PARAMETER Session + Session hashtable. Defaults to the current session. + .PARAMETER ForceRefresh + v1 only: request a new token even when the current one has not expired (used after a 401). + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $false)] + [hashtable]$Session = $script:InforcerSession, + + [Parameter(Mandatory = $false)] + [switch]$ForceRefresh + ) + + if (-not $Session) { + Write-Error -Message 'Not connected. Run Connect-Inforcer first.' -ErrorId 'NotConnected' -Category ConnectionError + return + } + + if ($Session.ApiVersion -eq 'v1') { + $expiresAt = $Session.TokenExpiresAt -as [datetime] + if ($ForceRefresh -or -not $Session.AccessToken -or -not $expiresAt -or $expiresAt -lt [datetime]::UtcNow.AddMinutes(5)) { + $token = Get-InforcerAccessToken -ApiHost $Session.ApiHost -ClientId $Session.ClientId -ClientSecret $Session.ClientSecret + if ($token.Error) { + Write-Error -Message $token.Error -ErrorId 'TokenRequestFailed' -Category AuthenticationError + return + } + $Session.AccessToken = $token.AccessToken + $Session.TokenExpiresAt = $token.ExpiresAt + } + return @{ Authorization = 'Bearer ' + (ConvertFrom-InforcerSecureString -SecureString $Session.AccessToken) } + } + + $apiKey = if ($Session.ApiKey -is [System.Security.SecureString]) { ConvertFrom-InforcerSecureString -SecureString $Session.ApiKey } + if ([string]::IsNullOrWhiteSpace($apiKey)) { + Write-Error -Message 'API key is empty or invalid. Please reconnect.' -ErrorId 'EmptyApiKey' -Category AuthenticationError + return + } + @{ 'Inf-Api-Key' = $apiKey } +} + +function Get-InforcerAccessToken { + <# + .SYNOPSIS + Requests an OAuth client-credentials access token for the v1 API (Private helper). + .DESCRIPTION + POST {ApiHost}/oauth/token (form-encoded). Returns AccessToken (SecureString), ExpiresAt (UTC) + and Error. On failure AccessToken is $null and Error holds a clean message that never contains + the secret. Writes no errors itself, so the caller decides how to report. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The token arrives as plain text from the token endpoint and is stored only as a SecureString.')] + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [string]$ApiHost, + + [Parameter(Mandatory = $true)] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [System.Security.SecureString]$ClientSecret + ) + + $secret = ConvertFrom-InforcerSecureString -SecureString $ClientSecret + $request = @{ + Uri = $ApiHost.TrimEnd('/') + '/oauth/token' + Method = 'POST' + Body = @{ grant_type = 'client_credentials'; client_id = $ClientId; client_secret = $secret } + ContentType = 'application/x-www-form-urlencoded' + Headers = @{ Accept = 'application/json' } + TimeoutSec = 30 + } + + $errorText = $null + $response = $null + try { + $result = Invoke-InforcerHttpRequest -Request $request + $response = $result.Body + if ($result.StatusCode -ge 400) { + $json = $response + if ($json -is [string]) { try { $json = $json | ConvertFrom-Json -Depth 100 -ErrorAction Stop } catch { $json = $null } } + $reason = $null + if ($json -is [System.Management.Automation.PSCustomObject]) { + $parts = foreach ($n in 'title', 'detail', 'error', 'error_description') { + $v = $json.PSObject.Properties[$n].Value -as [string] + if (-not [string]::IsNullOrWhiteSpace($v)) { $v.Trim().TrimEnd('.') } + } + $reason = @($parts | Select-Object -Unique) -join ': ' + } + if ([string]::IsNullOrWhiteSpace($reason)) { $reason = [string][System.Net.HttpStatusCode]$result.StatusCode } + $hint = if ($result.StatusCode -in 400, 401) { ' Check the client ID and secret, and that the client is active and not expired.' } else { '' } + $errorText = "Token request failed (HTTP $($result.StatusCode)): $reason.$hint" + } + } catch { + $errorText = "Token request failed: $($_.Exception.Message)" + } + + $accessToken = $null + if (-not $errorText) { + $accessToken = $response.PSObject.Properties['access_token'].Value -as [string] + if ([string]::IsNullOrWhiteSpace($accessToken)) { $errorText = 'Token request failed: the response contained no access_token.' } + } + if ($errorText) { + return [PSCustomObject]@{ AccessToken = $null; ExpiresAt = $null; Error = (Protect-InforcerApiKeyInText -Text $errorText -ApiKey $secret -Session @{}) } + } + + $lifetime = $response.PSObject.Properties['expires_in'].Value -as [int] + if ($null -eq $lifetime -or $lifetime -le 0) { $lifetime = 3600 } + + [PSCustomObject]@{ + AccessToken = ConvertTo-SecureString -String $accessToken -AsPlainText -Force + ExpiresAt = [datetime]::UtcNow.AddSeconds($lifetime) + Error = $null + } +} diff --git a/module/Private/Get-InforcerBaseUrl.ps1 b/module/Private/Get-InforcerBaseUrl.ps1 index 9a1148e..ef195b9 100644 --- a/module/Private/Get-InforcerBaseUrl.ps1 +++ b/module/Private/Get-InforcerBaseUrl.ps1 @@ -1,40 +1,76 @@ function Get-InforcerBaseUrl { <# .SYNOPSIS - Returns the Inforcer API base URL for a region or custom URL. + Returns the Inforcer API host for a region, or the full request URI for an endpoint (Private helper). .DESCRIPTION - Maps region codes (anz, eu, uk, us) to production API URLs, or returns - the provided -BaseUrl when specified. + Host mode (-Region / -BaseUrl): maps a region (anz, eu, uk, us) to its API host + (https://api-{region}.inforcer.com), or normalises -BaseUrl to a host by trimming a + trailing '/', '/api', '/api/beta' or '/v1'. Both 'https://host' and 'https://host/api' work. + + Endpoint mode (-Endpoint): builds the request URI for the session's API version. + A leading '/beta' on the endpoint is stripped, so callers can pass version-neutral paths. + beta: {BaseUrl}/beta{path} + v1: {ApiHost}/v1{path}, with the v1 renames applied: + /tenants/{id}/roles -> /tenants/{id}/directoryRoles + /auditEvents/eventTypes -> /auditEvents/types + query customBaselineId= -> baselineId= .PARAMETER Region Region code. Valid: anz, eu, uk, us. Ignored when -BaseUrl is set. .PARAMETER BaseUrl - Optional custom base URL. When set, this is returned (trimmed, no trailing slash). + Optional custom host (with or without '/api'). + .PARAMETER Endpoint + API path, version-neutral (e.g. /tenants/{id}/policies). A leading /beta is ignored. + .PARAMETER Session + Session hashtable for -Endpoint. Defaults to the current session. #> - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'Host')] [OutputType([string])] param( - [Parameter(Mandatory = $false)] + [Parameter(Mandatory = $false, ParameterSetName = 'Host')] [ValidateSet('anz', 'eu', 'uk', 'us', IgnoreCase = $true)] [string]$Region = 'uk', - [Parameter(Mandatory = $false)] - [string]$BaseUrl + [Parameter(Mandatory = $false, ParameterSetName = 'Host')] + [string]$BaseUrl, + + [Parameter(Mandatory = $true, ParameterSetName = 'Endpoint')] + [string]$Endpoint, + + [Parameter(Mandatory = $false, ParameterSetName = 'Endpoint')] + [hashtable]$Session = $script:InforcerSession ) + if ($PSCmdlet.ParameterSetName -eq 'Endpoint') { + if (-not $Session) { throw [System.InvalidOperationException]::new('Not connected. Run Connect-Inforcer first.') } + $path = $Endpoint.Trim() + if (-not $path.StartsWith('/')) { $path = '/' + $path } + $path = $path -replace '^/beta(?=/|\?|$)', '' + + if ($Session.ApiVersion -eq 'v1') { + $apiHost = $Session.ApiHost + if ([string]::IsNullOrWhiteSpace($apiHost)) { $apiHost = $Session.BaseUrl -replace '/api/?$', '' } + $path = $path -replace '^(/tenants/[^/?]+)/roles(?=/|\?|$)', '$1/directoryRoles' + $path = $path -replace '^/auditEvents/eventTypes(?=/|\?|$)', '/auditEvents/types' + $path = $path -replace '([?&])customBaselineId=', '${1}baselineId=' + return $apiHost.TrimEnd('/') + '/v1' + $path + } + return $Session.BaseUrl.TrimEnd('/') + '/beta' + $path + } + if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { - return $BaseUrl.Trim().TrimEnd('/') + return ($BaseUrl.Trim().TrimEnd('/') -replace '(?i)(/api(/beta)?|/v1)$', '') } $regionMap = @{ - 'anz' = 'https://api-anz.inforcer.com/api' - 'eu' = 'https://api-eu.inforcer.com/api' - 'uk' = 'https://api-uk.inforcer.com/api' - 'us' = 'https://api-us.inforcer.com/api' + 'anz' = 'https://api-anz.inforcer.com' + 'eu' = 'https://api-eu.inforcer.com' + 'uk' = 'https://api-uk.inforcer.com' + 'us' = 'https://api-us.inforcer.com' } - $key = $regionMap[$Region.ToLowerInvariant()] - if ($key) { - return $key + $apiHostForRegion = $regionMap[$Region.ToLowerInvariant()] + if ($apiHostForRegion) { + return $apiHostForRegion } throw [System.ArgumentException]::new( diff --git a/module/Private/Get-InforcerTenantList.ps1 b/module/Private/Get-InforcerTenantList.ps1 new file mode 100644 index 0000000..164cb14 --- /dev/null +++ b/module/Private/Get-InforcerTenantList.ps1 @@ -0,0 +1,33 @@ +function Get-InforcerTenantList { + <# + .SYNOPSIS + Returns the tenant list with canonical property names, cached per session (Private helper). + .DESCRIPTION + beta: GET /tenants (rich objects incl. clientTenantId). v1: GET /tenants mapped to canonical + names (msTenantId, tenantFriendlyName, tenantDnsName). Cached in $script:InforcerTenantListCache + for the session that fetched it, so swapping sessions (Compare-InforcerEnvironments) never + returns another account's tenants. Cleared with the other Inforcer*Cache* variables on + Connect/Disconnect. A failed request is not cached; its error goes to the error stream. + .PARAMETER Refresh + Ignore the cache and fetch again. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $false)] + [switch]$Refresh + ) + + $session = $script:InforcerSession + $cache = $script:InforcerTenantListCache + if (-not $Refresh -and $cache -and $session -and [object]::ReferenceEquals($cache.Session, $session)) { + return $cache.Tenants + } + + $requestErrors = $null + $tenants = @(Invoke-InforcerApiRequest -Endpoint '/tenants' -Method GET -ObjectType Tenant -ErrorVariable requestErrors) + if (-not $requestErrors -and $session) { + $script:InforcerTenantListCache = @{ Session = $session; Tenants = $tenants } + } + $tenants +} diff --git a/module/Private/Invoke-InforcerApiRequest.ps1 b/module/Private/Invoke-InforcerApiRequest.ps1 index 25ba153..94f73de 100644 --- a/module/Private/Invoke-InforcerApiRequest.ps1 +++ b/module/Private/Invoke-InforcerApiRequest.ps1 @@ -3,33 +3,40 @@ function Invoke-InforcerApiRequest { .SYNOPSIS Sends a request to the Inforcer REST API (Private helper). .DESCRIPTION - Uses the current session (Inf-Api-Key, BaseUrl), unwraps response.data, - and returns PSObjects or JSON string. All JSON serialization uses -Depth 100. - - Recognizes three error envelope shapes used by the Inforcer API: + Works for both login types. API-key sessions call {BaseUrl}/beta{path}; client-credentials + sessions call {ApiHost}/v1{path} with the v1 path renames (see Get-InforcerBaseUrl). + Endpoints are version-neutral; a leading '/beta' is stripped. + + beta responses: unwraps response.data, then (unless -PreserveStructure) the first array property. + v1 responses: a list response unwraps to its single collection array (metadata such as + continuationToken is ignored); a single object is never unwrapped. -ObjectType maps + v1 property names to the module's canonical names before any JSON conversion. + A non-empty failedPolicyTypes is surfaced as a warning. + + Recognised error envelopes: + * problem+json: { type, title, status, detail, instance, errors{} } -> "title: detail (field: message)" * App-layer: { success, message, errors[], errorCode } - * APIM gateway: { statusCode, message } (e.g. 404 on unsupported path) - * RFC 9110: { type, title, status, traceId } (e.g. 415 Unsupported Media Type) + * APIM gateway: { statusCode, message } + ErrorId is ApiRequestFailed_. 429 is retried up to three times honouring Retry-After + (seconds or HTTP date); a v1 401 renews the token once and retries. - The x-correlation-id response header is logged to the verbose stream on every - request and included in error messages when present. Surface it to the API team - when reporting issues. + The x-correlation-id response header is logged to the verbose stream and included in error + messages when present. .PARAMETER Endpoint - API path (e.g. /beta/tenants). Leading slash optional. + Version-neutral API path (e.g. /tenants/{id}/policies). Leading slash optional. .PARAMETER Method HTTP method. Default: GET. .PARAMETER Body - Optional JSON body for POST/PUT. + Optional JSON body for POST/PUT/PATCH. .PARAMETER OutputType PowerShellObject (return PSObjects) or JsonObject (return JSON string). Default: PowerShellObject. .PARAMETER PreserveStructure - When set, skips the automatic array-unwrapping step. The .data wrapper is still - unwrapped, but inner structure (e.g. items + continuationToken inside .data) is preserved. - Use this when the caller needs pagination metadata that lives inside the .data object. + beta: unwraps .data but keeps its inner structure (e.g. items + continuationToken). + v1: returns the response object as-is (collection mapped), without unwrapping. .PARAMETER PreserveFullResponse - When set, returns the full parsed API response without any unwrapping. Neither - .data nor arrays are unwrapped. Use this when pagination metadata (e.g. continuationToken) - lives at the response root level alongside .data, not inside it. + Returns the full parsed response without unwrapping (v1: collection mapped via -ObjectType). + .PARAMETER ObjectType + v1 only: maps the response to canonical property names via ConvertFrom-InforcerV1Response. #> [CmdletBinding()] param( @@ -51,7 +58,12 @@ function Invoke-InforcerApiRequest { [switch]$PreserveStructure, [Parameter(Mandatory = $false)] - [switch]$PreserveFullResponse + [switch]$PreserveFullResponse, + + [Parameter(Mandatory = $false)] + [ValidateSet('Tenant', 'Policy', 'Baseline', 'AlignmentScore', 'AlignmentDetails', 'User', 'Group', 'GroupMember', 'Role', + 'SecureScore', 'Assessment', 'AssessmentRun', 'ReportType', 'ReportRun', 'ReportOutput', 'AuditEvent', 'AuditEventType')] + [string]$ObjectType ) if (-not (Test-InforcerSession)) { @@ -61,147 +73,133 @@ function Invoke-InforcerApiRequest { return } - $endpoint = $Endpoint.Trim() - if (-not $endpoint.StartsWith('/')) { - $endpoint = '/' + $endpoint - } - - $uri = $script:InforcerSession.BaseUrl + $endpoint - - $apiKey = ConvertFrom-InforcerSecureString -SecureString $script:InforcerSession.ApiKey - - if ([string]::IsNullOrWhiteSpace($apiKey)) { - Write-Error -Message 'API key is empty or invalid. Please reconnect.' ` - -ErrorId 'EmptyApiKey' ` - -Category AuthenticationError - return - } + $session = $script:InforcerSession + $isV1 = $session.ApiVersion -eq 'v1' + $uri = Get-InforcerBaseUrl -Endpoint $Endpoint -Session $session Write-Verbose "Making $Method request to: $uri" - $headers = @{ - 'Inf-Api-Key' = $apiKey - 'Accept' = 'application/json' - 'Content-Type' = 'application/json' - } - - $responseHeaders = $null - $params = @{ - Uri = $uri - Method = $Method - Headers = $headers - ResponseHeadersVariable = 'responseHeaders' - } + $request = @{ Uri = $uri; Method = $Method } if (-not [string]::IsNullOrWhiteSpace($Body)) { - $params['Body'] = $Body + $request['Body'] = $Body } - try { - $rawResponse = Invoke-RestMethod @params - $correlationId = Get-InforcerHeaderValue -Headers $responseHeaders -Name 'x-correlation-id' + $retries429 = 0 + $renewToken = $false + $tokenRenewed = $false + while ($true) { + $headers = Get-InforcerAuthHeader -Session $session -ForceRefresh:$renewToken + if (-not $headers) { return } + $renewToken = $false + $headers['Accept'] = 'application/json' + $headers['Content-Type'] = 'application/json' + $attempt = $request.Clone() + $attempt['Headers'] = $headers + + try { + $response = Invoke-InforcerHttpRequest -Request $attempt + } catch { + $detail = Protect-InforcerApiKeyInText -Text $_.Exception.Message -Session $session + Write-Error -Message "Inforcer API request failed: $detail" -ErrorId 'ApiRequestFailed' -Category ConnectionError + return + } + + $statusCode = $response.StatusCode + $correlationId = Get-InforcerHeaderValue -Headers $response.Headers -Name 'x-correlation-id' if ($correlationId) { Write-Verbose "x-correlation-id: $correlationId" } - } catch { - $statusCode = 0 - $detail = $_.Exception.Message - $correlationId = $null - - if ($_.Exception.Response) { - $statusCode = [int]$_.Exception.Response.StatusCode - $correlationId = Get-InforcerHeaderValue -Headers $_.Exception.Response.Headers -Name 'x-correlation-id' + if ($statusCode -lt 400) { + $rawResponse = $response.Body + break } - # Response body: PS7 surfaces it via ErrorDetails.Message; PS5.1 needs a stream read. - $responseBody = $null - if ($_.ErrorDetails.Message) { - $responseBody = $_.ErrorDetails.Message - } elseif ($_.Exception.Response) { - $reader = $null - try { - $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) - $responseBody = $reader.ReadToEnd() - } catch { } finally { - if ($reader) { $reader.Dispose() } - } + if ($statusCode -eq 429 -and $retries429 -lt 3) { + $retries429++ + $delay = Get-InforcerRetryDelay -Value (Get-InforcerHeaderValue -Headers $response.Headers -Name 'Retry-After') -Attempt $retries429 + Write-Verbose "Rate limited (HTTP 429). Retrying in $delay second(s) (attempt $retries429 of 3)." + Start-Sleep -Seconds $delay + continue + } + if ($statusCode -eq 401 -and $isV1 -and -not $tokenRenewed) { + Write-Verbose 'HTTP 401: renewing the access token and retrying once.' + $tokenRenewed = $true + $renewToken = $true + continue } - if (-not [string]::IsNullOrWhiteSpace($responseBody)) { - $detail = $responseBody - try { - $json = $responseBody | ConvertFrom-Json -ErrorAction SilentlyContinue - } catch { - $json = $null - } + $detail = "Response status code does not indicate success: $statusCode ($([System.Net.HttpStatusCode]$statusCode))." + $json = $response.Body + if ($json -is [string]) { + if (-not [string]::IsNullOrWhiteSpace($json)) { $detail = $json } + try { $json = $json | ConvertFrom-Json -Depth 100 -ErrorAction Stop } catch { $json = $null } + } - if ($json) { - # Identify envelope shape and pull out a useful message + errorCode + status. - $apiMessage = $null - $errorCode = $null - $traceId = $null - - if ($null -ne $json.PSObject.Properties['errorCode'] -or $null -ne $json.PSObject.Properties['errors'] -or $null -ne $json.PSObject.Properties['success']) { - # Shape A: app-layer envelope { success, message, errors[], errorCode } - $errorCode = ($json.PSObject.Properties['errorCode'].Value -as [string]) - $apiMessage = ($json.PSObject.Properties['message'].Value -as [string]) - - # Surface structured field-level errors. The top-level message is often a - # generic placeholder ("Validation failed, see errors for details") — the - # useful information lives in the errors[] array. - $errorsProp = $json.PSObject.Properties['errors'] - if ($errorsProp -and $errorsProp.Value) { - $errorsJoined = Format-InforcerErrorDetail -Errors $errorsProp.Value - if (-not [string]::IsNullOrWhiteSpace($errorsJoined)) { - if ([string]::IsNullOrWhiteSpace($apiMessage)) { - $apiMessage = $errorsJoined - } elseif ($apiMessage -notlike "*$errorsJoined*") { - $apiMessage = "$apiMessage — $errorsJoined" - } + if ($json -is [System.Management.Automation.PSCustomObject]) { + if ($response.Body -isnot [string]) { $detail = $json | ConvertTo-Json -Depth 100 -Compress } + $apiMessage = $null + $errorCode = $null + $traceId = $null + $props = $json.PSObject.Properties + $isAppEnvelope = $null -ne $props['errorCode'] -or $null -ne $props['success'] + + if (-not $isAppEnvelope -and $null -ne $props['title'] -and ($null -ne $props['status'] -or $null -ne $props['type'])) { + # problem+json (RFC 9457): v1 errors, and beta framework errors such as 415. + $title = ($props['title'].Value -as [string]) + $problemDetail = ($props['detail'].Value -as [string]) + $apiMessage = if ($title -and $problemDetail -and $problemDetail -ne $title) { "${title}: $problemDetail" } + elseif ($problemDetail) { $problemDetail } else { $title } + $fieldErrors = Format-InforcerProblemError -Errors $props['errors'].Value + if ($fieldErrors) { $apiMessage = "$apiMessage ($fieldErrors)" } + $traceId = ($props['traceId'].Value -as [string]) + } elseif ($isAppEnvelope -or $null -ne $props['errors']) { + # App-layer envelope { success, message, errors[], errorCode } + $errorCode = ($props['errorCode'].Value -as [string]) + $apiMessage = ($props['message'].Value -as [string]) + $errorsProp = $props['errors'] + if ($errorsProp -and $errorsProp.Value) { + $errorsJoined = Format-InforcerErrorDetail -Errors $errorsProp.Value + if (-not [string]::IsNullOrWhiteSpace($errorsJoined)) { + if ([string]::IsNullOrWhiteSpace($apiMessage)) { + $apiMessage = $errorsJoined + } elseif ($apiMessage -notlike "*$errorsJoined*") { + $apiMessage = "$apiMessage — $errorsJoined" } } - } elseif ($null -ne $json.PSObject.Properties['statusCode'] -and $null -ne $json.PSObject.Properties['message']) { - # Shape B: APIM gateway { statusCode, message } — e.g. 404 on unsupported method/path - if ($statusCode -le 0) { $statusCode = [int]$json.statusCode } - $apiMessage = ($json.PSObject.Properties['message'].Value -as [string]) - } elseif ($null -ne $json.PSObject.Properties['title'] -and ($null -ne $json.PSObject.Properties['status'] -or $null -ne $json.PSObject.Properties['type'])) { - # Shape C: RFC 9110 ProblemDetails { type, title, status, traceId } — e.g. 415 - if ($statusCode -le 0 -and $null -ne $json.PSObject.Properties['status']) { - $statusCode = [int]$json.status - } - $apiMessage = ($json.PSObject.Properties['title'].Value -as [string]) - $traceId = ($json.PSObject.Properties['traceId'].Value -as [string]) } + } elseif ($null -ne $props['statusCode'] -and $null -ne $props['message']) { + # APIM gateway { statusCode, message } + $apiMessage = ($props['message'].Value -as [string]) + } - $detail = switch ($true) { - ($statusCode -eq 429 -or ($apiMessage -and $apiMessage -match 'quota|rate.?limit|throttl')) { - if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { "API rate limit: $apiMessage" } else { 'API rate limit exceeded. Please wait and try again.' } - } - ($errorCode -match '^forbidden$') { - if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } else { "You don't have permission to access this tenant or resource." } - } - ($errorCode -match 'notfound|not_found') { - if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } else { 'Tenant or resource not found.' } - } - default { - if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } - elseif ($json.PSObject.Properties['error']) { $json.error -as [string] } - else { $detail } - } + $detail = switch ($true) { + ($statusCode -eq 429 -or ($apiMessage -and $apiMessage -match 'quota|rate.?limit|throttl')) { + if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { "API rate limit: $apiMessage" } else { 'API rate limit exceeded. Please wait and try again.' } } - - if (-not [string]::IsNullOrWhiteSpace($traceId)) { - $detail = "$detail (traceId: $traceId)" + ($errorCode -match '^forbidden$') { + if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } else { "You don't have permission to access this tenant or resource." } + } + ($errorCode -match 'notfound|not_found') { + if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } else { 'Tenant or resource not found.' } + } + default { + if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } + elseif ($props['error']) { $json.error -as [string] } + else { $detail } } } + + if (-not [string]::IsNullOrWhiteSpace($traceId)) { + $detail = "$detail (traceId: $traceId)" + } } - $detail = Protect-InforcerApiKeyInText -Text $detail -ApiKey $apiKey - $msg = if ($statusCode -gt 0) { "Inforcer API request failed (HTTP $statusCode): $detail" } else { "Inforcer API request failed: $detail" } + $detail = Protect-InforcerApiKeyInText -Text $detail -Session $session + $msg = "Inforcer API request failed (HTTP $statusCode): $detail" if ($correlationId) { $msg = "$msg [correlation-id: $correlationId]" } - $errorId = if ($statusCode -gt 0) { "ApiRequestFailed_$statusCode" } else { 'ApiRequestFailed' } - Write-Error -Message $msg -ErrorId $errorId -Category ConnectionError + Write-Error -Message $msg -ErrorId "ApiRequestFailed_$statusCode" -Category ConnectionError return } @@ -210,9 +208,7 @@ function Invoke-InforcerApiRequest { return } - # Invoke-RestMethod auto-parses JSON to PSObject. If the response is a string, the - # API may have returned a non-JSON Content-Type (e.g. text/plain) despite the body - # being valid JSON. Try to parse it before reporting as non-JSON. + # Invoke-RestMethod auto-parses JSON. A string means a non-JSON Content-Type that may still hold JSON. if ($rawResponse -is [string]) { try { $rawResponse = $rawResponse | ConvertFrom-Json -Depth 100 -ErrorAction Stop @@ -232,7 +228,40 @@ function Invoke-InforcerApiRequest { return } - # API error payload (success: false) — single, clean message; no stack or raw JSON + if ($isV1) { + $collectionName = Get-InforcerV1CollectionName -Response $rawResponse + $failedProp = $rawResponse.PSObject.Properties['failedPolicyTypes'] + if ($failedProp -and $failedProp.Value -is [System.Management.Automation.PSCustomObject]) { + $failed = @($failedProp.Value.PSObject.Properties | ForEach-Object { + if ([string]::IsNullOrWhiteSpace([string]$_.Value)) { $_.Name } else { "$($_.Name) ($($_.Value))" } + }) + if ($failed.Count -gt 0) { + Write-Warning "inforcer could not retrieve $($failed.Count) policy type(s); their policies are missing from this result: $($failed -join '; ')" + } + } + + # Plain assignments only: an if-expression would unroll a one-item or empty array. + if ($collectionName) { + $data = @(@($rawResponse.$collectionName).Where({ $null -ne $_ })) + if ($ObjectType) { $data = @(ConvertFrom-InforcerV1Response -InputObject $data -ObjectType $ObjectType) } + } else { + $data = $rawResponse + if ($ObjectType) { $data = ConvertFrom-InforcerV1Response -InputObject $data -ObjectType $ObjectType } + } + + $result = $data + if ($collectionName -and ($PreserveFullResponse -or $PreserveStructure)) { + $full = [ordered]@{} + foreach ($p in $rawResponse.PSObject.Properties) { $full[$p.Name] = $p.Value } + $full[$collectionName] = $data + $result = [PSCustomObject]$full + } + if ($PreserveFullResponse) { return $result } + if ($OutputType -eq 'JsonObject') { return (ConvertTo-Json -InputObject $result -Depth 100) } + return $result + } + + # beta: API error payload (success: false) — single, clean message; no stack or raw JSON $successValue = $rawResponse.PSObject.Properties['success'].Value if ($null -ne $rawResponse.PSObject.Properties['success'] -and $successValue -eq $false) { $apiMessage = ($rawResponse.PSObject.Properties['message'].Value -as [string]) @@ -283,3 +312,51 @@ function Invoke-InforcerApiRequest { $data } + +function Invoke-InforcerHttpRequest { + # Invoke-RestMethod that returns HTTP errors instead of throwing them, so a caller's -ErrorVariable + # only ever holds the module's own error record. Network failures still throw. + param([Parameter(Mandatory = $true)][hashtable]$Request) + + $statusCode = $null + $responseHeaders = $null + $body = Invoke-RestMethod @Request -SkipHttpErrorCheck -StatusCodeVariable statusCode -ResponseHeadersVariable responseHeaders -Verbose:$false -Debug:$false + [PSCustomObject]@{ + StatusCode = if ($statusCode) { [int]$statusCode } else { 200 } + Body = $body + Headers = $responseHeaders + } +} + +function Format-InforcerProblemError { + # problem+json errors{}: { "": ["message", ...] } -> "field: message; field: message" + param($Errors) + + if ($Errors -isnot [System.Management.Automation.PSCustomObject]) { return Format-InforcerErrorDetail -Errors $Errors } + $parts = foreach ($p in $Errors.PSObject.Properties) { + $messages = @($p.Value | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join ', ' + if ($messages) { "$($p.Name): $messages" } else { $p.Name } + } + if ($parts) { @($parts) -join '; ' } +} + +function Get-InforcerRetryDelay { + # Retry-After is either delay-seconds or an HTTP date; falls back to exponential backoff. Capped at 300s. + param([string]$Value, [int]$Attempt = 1) + + $seconds = 0 + if (-not [string]::IsNullOrWhiteSpace($Value)) { + $date = [datetimeoffset]::MinValue + if ([int]::TryParse($Value.Trim(), [ref]$seconds)) { + $seconds = [Math]::Max(0, $seconds) + } elseif ([datetimeoffset]::TryParse($Value.Trim(), [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$date)) { + $seconds = [int][Math]::Ceiling(($date - [datetimeoffset]::UtcNow).TotalSeconds) + $seconds = [Math]::Max(0, $seconds) + } else { + $seconds = [int][Math]::Pow(2, $Attempt) + } + } else { + $seconds = [int][Math]::Pow(2, $Attempt) + } + [Math]::Min(300, [Math]::Max(1, $seconds)) +} diff --git a/module/Private/Invoke-InforcerPagedRequest.ps1 b/module/Private/Invoke-InforcerPagedRequest.ps1 new file mode 100644 index 0000000..5ece31c --- /dev/null +++ b/module/Private/Invoke-InforcerPagedRequest.ps1 @@ -0,0 +1,104 @@ +function Invoke-InforcerPagedRequest { + <# + .SYNOPSIS + Returns all items of a paged Inforcer API list, following continuationToken (Private helper). + .DESCRIPTION + Works for both API versions. GET passes continuationToken in the query string; POST passes it in + the JSON body. Items come from the beta .data array (or the array inside .data, e.g. .data.items) + or the v1 collection. Stops when the token is empty or repeats, or when -MaxResults is reached. + An API error is written to the error stream and ends paging; items already read are returned. + .PARAMETER Endpoint + Version-neutral API path, may already contain a query string. + .PARAMETER Method + GET (default) or POST. + .PARAMETER Body + POST body as a hashtable; continuationToken is added per page. + .PARAMETER ObjectType + v1 only: maps items to canonical property names. + .PARAMETER MaxResults + Stop after this many items. 0 = no limit. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $true)] + [string]$Endpoint, + + [Parameter(Mandatory = $false)] + [ValidateSet('GET', 'POST')] + [string]$Method = 'GET', + + [Parameter(Mandatory = $false)] + [hashtable]$Body, + + [Parameter(Mandatory = $false)] + [ValidateSet('Tenant', 'Policy', 'Baseline', 'AlignmentScore', 'AlignmentDetails', 'User', 'Group', 'GroupMember', 'Role', + 'SecureScore', 'Assessment', 'AssessmentRun', 'ReportType', 'ReportRun', 'ReportOutput', 'AuditEvent', 'AuditEventType')] + [string]$ObjectType, + + [Parameter(Mandatory = $false)] + [ValidateRange(0, [int]::MaxValue)] + [int]$MaxResults = 0 + ) + + $items = [System.Collections.Generic.List[object]]::new() + $seenTokens = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $token = $null + $page = 0 + + do { + $page++ + $request = @{ Method = $Method; PreserveFullResponse = $true } + if ($ObjectType) { $request['ObjectType'] = $ObjectType } + if ($Method -eq 'POST') { + $pageBody = if ($Body) { $Body.Clone() } else { @{} } + if ($token) { $pageBody['continuationToken'] = $token } else { $pageBody.Remove('continuationToken') } + $request['Body'] = $pageBody | ConvertTo-Json -Depth 100 -Compress + $request['Endpoint'] = $Endpoint + } else { + $request['Endpoint'] = if ($token) { + $Endpoint + $(if ($Endpoint.Contains('?')) { '&' } else { '?' }) + 'continuationToken=' + [System.Uri]::EscapeDataString($token) + } else { $Endpoint } + } + + $response = Invoke-InforcerApiRequest @request + if ($null -eq $response) { break } + + $pageItems = $null + $container = $response + $dataProp = $response.PSObject.Properties['data'] + if ($dataProp) { + $container = $dataProp.Value + if ($container -is [array]) { $pageItems = $container } + elseif ($container -is [System.Management.Automation.PSCustomObject]) { + $arrayProp = $container.PSObject.Properties | Where-Object { $_.Value -is [array] } | Select-Object -First 1 + if ($arrayProp) { $pageItems = $arrayProp.Value } + } + } else { + $collectionName = Get-InforcerV1CollectionName -Response $response + if ($collectionName) { $pageItems = $response.$collectionName } + elseif ($response -is [array]) { $pageItems = $response } + } + + foreach ($i in @($pageItems)) { + if ($null -eq $i) { continue } + $items.Add($i) + if ($MaxResults -gt 0 -and $items.Count -ge $MaxResults) { break } + } + if ($MaxResults -gt 0 -and $items.Count -ge $MaxResults) { break } + + $next = $response.PSObject.Properties['continuationToken'].Value -as [string] + if ([string]::IsNullOrWhiteSpace($next) -and $container -is [System.Management.Automation.PSCustomObject]) { + $next = $container.PSObject.Properties['continuationToken'].Value -as [string] + } + if ([string]::IsNullOrWhiteSpace($next)) { break } + if (-not $seenTokens.Add($next)) { + Write-Verbose 'API returned a continuationToken it already returned. Stopping pagination.' + break + } + Write-Verbose "Page $page returned $(@($pageItems).Count) item(s); fetching the next page." + $token = $next + } while ($true) + + $items.ToArray() +} diff --git a/module/Private/Protect-InforcerApiKeyInText.ps1 b/module/Private/Protect-InforcerApiKeyInText.ps1 index fa4aaa7..3e55dcf 100644 --- a/module/Private/Protect-InforcerApiKeyInText.ps1 +++ b/module/Private/Protect-InforcerApiKeyInText.ps1 @@ -1,14 +1,19 @@ function Protect-InforcerApiKeyInText { <# .SYNOPSIS - Replaces every occurrence of the live API key in a text string with [REDACTED] (Private helper). + Replaces every credential in a text string with [REDACTED] (Private helper). .DESCRIPTION - Used by error-message construction in API helpers to ensure that exception text, response - bodies, and verbose dumps never leak the active subscription key. + Used by error-message construction in API helpers so exception text, response bodies and + verbose dumps never leak a credential. Redacts: + * the -ApiKey value, when given + * the session's API key, client secret and access token + * any bearer token or JWT-shaped string .PARAMETER Text - The input text — may contain the API key anywhere. + The input text. .PARAMETER ApiKey - The plain-text API key to redact. If $null/empty, returns the input unchanged. + Optional extra plain-text secret to redact (kept for existing callers). + .PARAMETER Session + Session whose secrets are redacted. Defaults to the current session. #> [CmdletBinding()] [OutputType([string])] @@ -16,14 +21,26 @@ function Protect-InforcerApiKeyInText { [Parameter(Mandatory = $false, Position = 0)] [string]$Text, - [Parameter(Mandatory = $true, Position = 1)] - [string]$ApiKey + [Parameter(Mandatory = $false, Position = 1)] + [string]$ApiKey, + + [Parameter(Mandatory = $false)] + [hashtable]$Session = $script:InforcerSession ) - if ([string]::IsNullOrWhiteSpace($Text) -or [string]::IsNullOrWhiteSpace($ApiKey)) { - return $Text + if ([string]::IsNullOrWhiteSpace($Text)) { return $Text } + + $secrets = [System.Collections.Generic.List[string]]::new() + if (-not [string]::IsNullOrWhiteSpace($ApiKey)) { $secrets.Add($ApiKey) } + if ($Session) { + foreach ($name in 'ApiKey', 'ClientSecret', 'AccessToken') { + $value = $Session[$name] + if ($value -is [System.Security.SecureString] -and $value.Length -gt 0) { + $secrets.Add([System.Net.NetworkCredential]::new('', $value).Password) + } + } } - # No caching here, so a non-compiled regex is faster than building a fresh compiled one - # per call — compilation cost only amortizes when the regex object is reused. - [regex]::Replace($Text, [regex]::Escape($ApiKey), '[REDACTED]') + foreach ($s in $secrets) { $Text = $Text.Replace($s, '[REDACTED]') } + $Text = [regex]::Replace($Text, '(?i)\bbearer\s+[A-Za-z0-9\-._~+/]+=*', 'Bearer [REDACTED]') + [regex]::Replace($Text, '\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*', '[REDACTED]') } diff --git a/module/Private/Resolve-InforcerTenantId.ps1 b/module/Private/Resolve-InforcerTenantId.ps1 index 712082a..2efc94e 100644 --- a/module/Private/Resolve-InforcerTenantId.ps1 +++ b/module/Private/Resolve-InforcerTenantId.ps1 @@ -1,21 +1,24 @@ function Resolve-InforcerTenantId { <# .SYNOPSIS - Resolves a tenant identifier to client tenant ID (integer). + Resolves a tenant identifier to the id the current API expects. .DESCRIPTION - Accepts a numeric Client Tenant ID, a Microsoft Tenant ID (GUID), or a tenant name - (matched against tenantFriendlyName and tenantDnsName, case-insensitive). - Returns the resolved client tenant ID as an integer. + API-key session (beta): accepts a numeric Client Tenant ID, a Microsoft Tenant ID (GUID) or a + tenant name, and returns the Client Tenant ID as [int]. + Client-credentials session (v1): accepts a Microsoft Tenant ID (GUID) or a tenant name, and + returns the Microsoft Tenant ID as a string. Numeric ids exist only on the beta API and are + refused with ErrorId TenantIdNumericNotSupportedOnV1. + Names match tenantFriendlyName or tenantDnsName, case-insensitive. .PARAMETER TenantId - Client Tenant ID (integer), Microsoft Tenant ID (GUID), or tenant name (string). + Client Tenant ID (beta only), Microsoft Tenant ID (GUID), or tenant name. .PARAMETER TenantData - Optional. Pre-fetched array of tenant objects (e.g. from GET /beta/tenants). When provided, - resolution is done from this data instead of calling the API. + Optional pre-fetched tenant objects. When provided, resolution uses them instead of + Get-InforcerTenantList. .OUTPUTS - System.Int32 - The resolved client tenant ID. + System.Int32 (beta) or System.String (v1). #> [CmdletBinding()] - [OutputType([int])] + [OutputType([int], [string])] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [object]$TenantId, @@ -25,26 +28,32 @@ function Resolve-InforcerTenantId { ) process { + $isV1 = $script:InforcerSession -and $script:InforcerSession.ApiVersion -eq 'v1' $tenantIdString = $TenantId.ToString().Trim() - # Numeric Client Tenant ID — return directly $parsedInt = 0 if ([int]::TryParse($tenantIdString, [ref]$parsedInt)) { - $resolved = $parsedInt - Write-Verbose "Client Tenant ID detected: $resolved" - return $resolved + if ($isV1) { + $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new( + [System.ArgumentException]::new("Numeric tenant id '$tenantIdString' only works when you connect with an API key. With client credentials, pass the Microsoft tenant ID (GUID) or the tenant name; run Get-InforcerTenant to list them."), + 'TenantIdNumericNotSupportedOnV1', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $TenantId)) + } + Write-Verbose "Client Tenant ID detected: $parsedInt" + return $parsedInt } - # GUID or tenant name — both require tenant list lookup - $tenantsToUse = $TenantData - if ($null -eq $tenantsToUse -or $tenantsToUse.Count -eq 0) { - $tenantsToUse = @(Invoke-InforcerApiRequest -Endpoint '/beta/tenants' -Method GET -OutputType PowerShellObject) + $guidResult = [guid]::Empty + $isGuid = [guid]::TryParse($tenantIdString, [ref]$guidResult) + if ($isGuid -and $isV1) { + Write-Verbose "Microsoft Tenant ID (GUID) detected: $guidResult" + return $guidResult.ToString() } - $tenants = if ($tenantsToUse -is [array]) { $tenantsToUse } else { @($tenantsToUse) } - # GUID — match on msTenantId - $guidResult = [guid]::Empty - if ([guid]::TryParse($tenantIdString, [ref]$guidResult)) { + $tenants = if ($null -ne $TenantData -and $TenantData.Count -gt 0) { $TenantData } else { @(Get-InforcerTenantList) } + + if ($isGuid) { Write-Verbose "Microsoft Tenant ID (GUID) detected: $tenantIdString. Looking up client tenant ID..." foreach ($t in $tenants) { $msId = $t.PSObject.Properties['msTenantId'].Value @@ -60,35 +69,35 @@ function Resolve-InforcerTenantId { throw [System.InvalidOperationException]::new("No tenant found with Microsoft Tenant ID: $tenantIdString") } - # Tenant name — match on tenantFriendlyName or tenantDnsName (case-insensitive) Write-Verbose "Tenant name detected: '$tenantIdString'. Looking up by name..." - $foundTenants = @() + $foundTenants = [System.Collections.Generic.List[object]]::new() foreach ($t in $tenants) { - $friendlyName = $t.PSObject.Properties['tenantFriendlyName'].Value - $dnsName = $t.PSObject.Properties['tenantDnsName'].Value + $props = $t.PSObject.Properties + $friendlyName = $props['tenantFriendlyName'].Value + if ($null -eq $friendlyName) { $friendlyName = $props['name'].Value } + $dnsName = $props['tenantDnsName'].Value + if ($null -eq $dnsName) { $dnsName = $props['dnsName'].Value } if (($friendlyName -and $friendlyName -eq $tenantIdString) -or ($dnsName -and $dnsName -eq $tenantIdString)) { - $foundTenants += $t + $foundTenants.Add($t) } } if ($foundTenants.Count -eq 1) { - $clientId = $foundTenants[0].PSObject.Properties['clientTenantId'].Value - if ($null -ne $clientId) { - $resolved = [int]$clientId - Write-Verbose "Found tenant '$tenantIdString'. Client Tenant ID: $resolved" + $idProperty = if ($isV1) { 'msTenantId' } else { 'clientTenantId' } + $resolvedId = $foundTenants[0].PSObject.Properties[$idProperty].Value + if ($null -ne $resolvedId) { + $resolved = if ($isV1) { $resolvedId.ToString() } else { [int]$resolvedId } + Write-Verbose "Found tenant '$tenantIdString'. Tenant ID: $resolved" return $resolved } } if ($foundTenants.Count -gt 1) { - # Don't leak the matching tenants' Client Tenant IDs into the error message — - # in shared-MSP setups multiple Inforcer customers might share a tenant name and - # exposing the integer IDs reveals information about other accounts. Tell the - # user the count and require them to list tenants themselves (which is gated by - # the Tenants.Read scope they already need for any name-based resolution). + # Don't list the matching tenants' ids: in shared-MSP setups that reveals other accounts. $count = $foundTenants.Count - throw [System.InvalidOperationException]::new("$count tenants match name '$tenantIdString'. Pass the numeric Client Tenant ID for the one you want; run Get-InforcerTenant to list available tenants.") + $idHint = if ($isV1) { 'Microsoft tenant ID (GUID)' } else { 'numeric Client Tenant ID' } + throw [System.InvalidOperationException]::new("$count tenants match name '$tenantIdString'. Pass the $idHint for the one you want; run Get-InforcerTenant to list available tenants.") } throw [System.InvalidOperationException]::new("No tenant found with name '$tenantIdString'. Use Get-InforcerTenant to list available tenants.") diff --git a/module/Private/Test-InforcerSession.ps1 b/module/Private/Test-InforcerSession.ps1 index b71e6fb..c634813 100644 --- a/module/Private/Test-InforcerSession.ps1 +++ b/module/Private/Test-InforcerSession.ps1 @@ -3,23 +3,26 @@ function Test-InforcerSession { .SYNOPSIS Verifies that an Inforcer API session is active. .DESCRIPTION - Checks that $script:InforcerSession exists and has a non-empty ApiKey and BaseUrl. + True when the session has a host (ApiHost or BaseUrl) and a non-empty credential: + an ApiKey (API-key login, beta API) or a ClientSecret (client-credentials login, v1 API). Used by all data cmdlets before making API calls. Does not throw. + .PARAMETER Session + Session hashtable to test. Defaults to the current session. .OUTPUTS System.Boolean - True if session is valid, false otherwise. #> [CmdletBinding()] [OutputType([bool])] - param() + param( + [Parameter(Mandatory = $false)] + [hashtable]$Session = $script:InforcerSession + ) - if (-not $script:InforcerSession) { - return $false - } + if (-not $Session) { return $false } - $session = $script:InforcerSession - if (-not $session.ApiKey -or -not $session.BaseUrl) { - return $false - } + $hasHost = -not [string]::IsNullOrWhiteSpace($Session.ApiHost) -or -not [string]::IsNullOrWhiteSpace($Session.BaseUrl) + if (-not $hasHost) { return $false } - return ($session.ApiKey.Length -gt 0) -and -not [string]::IsNullOrWhiteSpace($session.BaseUrl) + $credential = if ($Session.ApiVersion -eq 'v1') { $Session.ClientSecret } else { $Session.ApiKey } + return ($null -ne $credential -and $credential.Length -gt 0) } diff --git a/module/Public/Connect-Inforcer.ps1 b/module/Public/Connect-Inforcer.ps1 index 9c9472c..332bd26 100644 --- a/module/Public/Connect-Inforcer.ps1 +++ b/module/Public/Connect-Inforcer.ps1 @@ -1,21 +1,33 @@ <# .SYNOPSIS - Establishes a secure connection to the Inforcer REST API. + 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. + Two ways to log in; the API version follows from the login: + * -ClientId / -ClientSecret (OAuth client credentials) uses the current v1 API. The connection is + validated by requesting an access token; the token is renewed automatically before it expires. + * -ApiKey uses the legacy beta API. API keys are due to be retired by inforcer, so this login shows + a warning. Before returning Connected, a minimal API call validates the key; an expired, revoked + or invalid key is refused. A valid key that lacks the probe endpoint's scope still connects. + You can specify -Region (uk, eu, us, anz) or -BaseUrl for a custom host. Secrets and tokens are + stored only as SecureString. .PARAMETER ApiKey - The Inforcer API key. Can be SecureString or String (converted to SecureString). + The inforcer API key (legacy beta API). Can be SecureString or String (converted to SecureString). +.PARAMETER ClientId + The client ID of an inforcer API client (v1 API). Create one in the inforcer portal under + Configure > REST API. +.PARAMETER ClientSecret + The client secret of the API client. 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. + Optional custom API host, with or without '/api' (e.g. https://api.example.com or + https://api.example.com/api). When set, -Region is ignored. +.EXAMPLE + Connect-Inforcer -ClientId $clientId -ClientSecret (Read-Host -AsSecureString -Prompt 'Client secret') -Region uk + Connects with an API client (v1 API). .EXAMPLE Connect-Inforcer -ApiKey "your-api-key" -Region uk + Connects with a legacy API key (beta API). .EXAMPLE $key = Read-Host -AsSecureString -Prompt "API Key"; Connect-Inforcer -ApiKey $key -Region uk .EXAMPLE @@ -23,7 +35,7 @@ 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. + 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 @@ -31,7 +43,8 @@ .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. + for cross-account comparison with Compare-InforcerEnvironments. Secrets and tokens in it + are SecureStrings. .OUTPUTS PSObject with Status, Region, BaseUrl, ConnectedAt. .LINK @@ -43,14 +56,23 @@ #> 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)] + Justification = 'Intentional convenience: users may pass a plain-text API key or client secret which is converted to SecureString for storage.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'ClientSecret', + Justification = 'Accepts SecureString or String; a String is converted to SecureString immediately.')] +[CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'ApiKey')] [OutputType([PSObject])] param( - [Parameter(Mandatory = $true, Position = 0)] + [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'ApiKey')] [Alias('Key')] [object]$ApiKey, + [Parameter(Mandatory = $true, ParameterSetName = 'ClientCredentials')] + [ValidateNotNullOrEmpty()] + [string]$ClientId, + + [Parameter(Mandatory = $true, ParameterSetName = 'ClientCredentials')] + [object]$ClientSecret, + [Parameter(Mandatory = $false, Position = 1)] [ValidateSet('anz', 'eu', 'uk', 'us', IgnoreCase = $true)] [string]$Region = 'uk', @@ -65,30 +87,38 @@ param( [switch]$PassThru ) -$rawApiKey = $ApiKey -while ($rawApiKey -is [PSObject] -and $rawApiKey.BaseObject) { - $rawApiKey = $rawApiKey.BaseObject +$isClientCredentials = $PSCmdlet.ParameterSetName -eq 'ClientCredentials' +$credentialLabel = if ($isClientCredentials) { 'ClientSecret' } else { 'ApiKey' } + +$rawSecret = if ($isClientCredentials) { $ClientSecret } else { $ApiKey } +while ($rawSecret -is [PSObject] -and $rawSecret.BaseObject) { + $rawSecret = $rawSecret.BaseObject } -$secureApiKey = $null -if ($rawApiKey -is [System.Security.SecureString]) { - $secureApiKey = $rawApiKey -} elseif ($rawApiKey -is [string]) { - $secureApiKey = ConvertTo-SecureString -String $rawApiKey -AsPlainText -Force +$secureSecret = $null +if ($rawSecret -is [System.Security.SecureString]) { + $secureSecret = $rawSecret +} elseif ($rawSecret -is [string]) { + $secureSecret = ConvertTo-SecureString -String $rawSecret -AsPlainText -Force } else { - Write-Error -Message "ApiKey must be a String or SecureString. Received: $($rawApiKey.GetType().Name)" ` - -ErrorId 'InvalidApiKeyType' -Category InvalidArgument + $typeName = if ($null -eq $rawSecret) { 'null' } else { $rawSecret.GetType().Name } + Write-Error -Message "$credentialLabel must be a String or SecureString. Received: $typeName" ` + -ErrorId $(if ($isClientCredentials) { 'InvalidClientSecretType' } else { 'InvalidApiKeyType' }) -Category InvalidArgument return } +$rawSecret = $null -$plain = ConvertFrom-InforcerSecureString -SecureString $secureApiKey -if ([string]::IsNullOrWhiteSpace($plain)) { - Write-Error -Message 'API key cannot be empty.' -ErrorId 'EmptyApiKey' -Category InvalidArgument +if ($secureSecret.Length -eq 0 -or [string]::IsNullOrWhiteSpace((ConvertFrom-InforcerSecureString -SecureString $secureSecret))) { + if ($isClientCredentials) { + Write-Error -Message 'Client secret cannot be empty.' -ErrorId 'EmptyClientSecret' -Category InvalidArgument + } else { + Write-Error -Message 'API key cannot be empty.' -ErrorId 'EmptyApiKey' -Category InvalidArgument + } return } try { - $baseUrlValue = Get-InforcerBaseUrl -Region $Region -BaseUrl $BaseUrl + $apiHost = Get-InforcerBaseUrl -Region $Region -BaseUrl $BaseUrl } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidRegion' -Category InvalidArgument return @@ -96,163 +126,192 @@ try { 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' -} +if ($isClientCredentials) { + $newSession = @{ + AuthMode = 'ClientCredentials' + ApiVersion = 'v1' + ApiHost = $apiHost + BaseUrl = $apiHost + Region = $Region + ClientId = $ClientId + ClientSecret = $secureSecret + } + # Validation = a successful token request. No scope probe: any valid client connects, whatever it may read. + $token = Get-InforcerAccessToken -ApiHost $apiHost -ClientId $ClientId -ClientSecret $secureSecret + if ($token.Error) { + Write-Error -Message "Connection failed: $($token.Error)" -ErrorId 'ConnectionValidationFailed' -Category AuthenticationError + return + } + $newSession['AccessToken'] = $token.AccessToken + $newSession['TokenExpiresAt'] = $token.ExpiresAt + $baseUrlValue = $apiHost +} else { + $baseUrlValue = $apiHost + '/api' + $newSession = @{ + AuthMode = 'ApiKey' + ApiVersion = 'beta' + ApiHost = $apiHost + BaseUrl = $baseUrlValue + Region = $Region + ApiKey = $secureSecret + } -$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 + # 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 = Get-InforcerAuthHeader -Session $newSession + $validateHeaders['Accept'] = 'application/json' -# 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 -} + $probeUri = Get-InforcerBaseUrl -Endpoint '/baselines' -Session $newSession + $probeSucceeded = $false + $probeStatusCode = 0 + $probeApiMessage = $null + $probeErrorsText = $null # errors[] only, without the generic top-level message + $probeEnvelope = 'unknown' # 'inforcer' | 'apim' | 'unknown' + $probeRawError = $null -$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 } + # 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 -Verbose:$false -Debug:$false -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: $(Protect-InforcerApiKeyInText -Text $_.Exception.Message -Session $newSession)" ` + -ErrorId 'ConnectionValidationFailed' -Category ConnectionError + return } - 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" } - } + $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' } + # 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' + # 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 } -} + # 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 ($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.' } + 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 (Protect-InforcerApiKeyInText -Text $msg -Session $newSession) -ErrorId 'ConnectionValidationFailed' -Category AuthenticationError + return } - Write-Error -Message $msg -ErrorId 'ConnectionValidationFailed' -Category AuthenticationError - return } # Clear stale caches from any prior session before establishing the new one. This catches @@ -266,14 +325,16 @@ if ($cacheVarsToClear) { 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 -} +$newSession['ConnectedAt'] = Get-Date +$script:InforcerSession = $newSession -Write-Verbose "Successfully connected to Inforcer API at $baseUrlValue" +Write-Verbose "Successfully connected to Inforcer API at $baseUrlValue ($($newSession.ApiVersion) API)" +if (-not $isClientCredentials) { + Write-Warning ('You connected with an API key, which uses inforcer''s legacy beta API. API keys are due to be retired. ' + + 'Switch to an API client: create one in the inforcer portal (Configure > REST API), then run ' + + 'Connect-Inforcer -ClientId -ClientSecret -Region ' + $Region + '. ' + + 'With an API client, pass the Microsoft tenant ID (GUID) or tenant name instead of the numeric tenant id.') +} # Best-effort prime of the Reports catalog cache so Get-InforcerReportType / # Invoke-InforcerReport argument completers show the live key list on the very @@ -287,17 +348,24 @@ if ($script:InforcerReportTypeCache -and @($script:InforcerReportTypeCache).Coun $skipPrime = $true } if (-not $skipPrime) { try { - $catalogProbe = Invoke-WebRequest -Uri ($baseUrlValue.TrimEnd('/') + '/beta/reports/types') ` - -Method GET -Headers $validateHeaders -UseBasicParsing -SkipHttpErrorCheck ` + $catalogHeaders = Get-InforcerAuthHeader -Session $newSession + $catalogHeaders['Accept'] = 'application/json' + $catalogProbe = Invoke-WebRequest -Uri (Get-InforcerBaseUrl -Endpoint '/reports/types' -Session $newSession) ` + -Method GET -Headers $catalogHeaders -UseBasicParsing -SkipHttpErrorCheck -Verbose:$false -Debug:$false ` -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) } + $parsed = $catalogJson | ConvertFrom-Json -Depth 100 -ErrorAction Stop + $entries = if ($isClientCredentials) { + $listProp = $parsed.PSObject.Properties['reportTypes'] + @(ConvertFrom-InforcerV1Response -InputObject @($(if ($listProp) { $listProp.Value })) -ObjectType ReportType) + } else { + $dataProp = $parsed.PSObject.Properties['data'] + if ($dataProp) { @($dataProp.Value) } else { @($parsed) } + } if ($entries.Count -gt 0) { $script:InforcerReportTypeCache = $entries $script:InforcerReportTypeCacheStamp = Get-Date @@ -305,21 +373,15 @@ if (-not $skipPrime) { try { } } } else { - Write-Verbose "Skipping Reports catalog prime: HTTP $([int]$catalogProbe.StatusCode) (key likely lacks Reports.Read)." + Write-Verbose "Skipping Reports catalog prime: HTTP $([int]$catalogProbe.StatusCode) (missing Reports read permission)." } } catch { - Write-Verbose "Skipping Reports catalog prime: $($_.Exception.Message)" + Write-Verbose "Skipping Reports catalog prime: $(Protect-InforcerApiKeyInText -Text $_.Exception.Message -Session $newSession)" } } # 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 + # An independent copy; secrets and tokens in it are SecureStrings only. + Write-Output $script:InforcerSession.Clone() } # Also connect to Microsoft Graph if requested diff --git a/module/Public/Disconnect-Inforcer.ps1 b/module/Public/Disconnect-Inforcer.ps1 index 07bbe7c..8ae63b9 100644 --- a/module/Public/Disconnect-Inforcer.ps1 +++ b/module/Public/Disconnect-Inforcer.ps1 @@ -2,7 +2,7 @@ .SYNOPSIS Disconnects from the Inforcer API and clears the session. .DESCRIPTION - Removes the stored session and clears authentication from memory. + Removes the stored session (API key, or client secret and access token) and clears authentication from memory. Always outputs a string: "Disconnected from Inforcer API." when a session was active, or "No active session to disconnect." when there was no session. .EXAMPLE @@ -23,7 +23,7 @@ function Disconnect-Inforcer { [OutputType([string])] param() -if ($script:InforcerSession -and $script:InforcerSession.ApiKey -and $script:InforcerSession.BaseUrl) { +if (Test-InforcerSession) { if ($PSCmdlet.ShouldProcess('Inforcer session', 'Disconnect')) { $script:InforcerSession = $null $script:InforcerSettingsCatalog = $null diff --git a/module/Public/Test-InforcerConnection.ps1 b/module/Public/Test-InforcerConnection.ps1 index 9916c5a..f2f4467 100644 --- a/module/Public/Test-InforcerConnection.ps1 +++ b/module/Public/Test-InforcerConnection.ps1 @@ -2,16 +2,16 @@ .SYNOPSIS Tests the Inforcer API connection. .DESCRIPTION - Makes a test request to the /beta/baselines endpoint to verify the current session and API key - work, and returns $true or $false so the result can be used as a condition. + Makes a test request to the baselines endpoint to verify the current session and its credential + (API key or API client) work, and returns $true or $false so the result can be used as a condition. Failures are reported as warnings rather than errors, so `Test-InforcerConnection -ErrorAction Stop` still answers the question instead of throwing. .EXAMPLE - Connect-Inforcer -ApiKey $env:INFORCER_API_KEY -Region uk; Test-InforcerConnection + Connect-Inforcer -ClientId $clientId -ClientSecret $secret -Region uk; Test-InforcerConnection Connects then verifies the connection. .EXAMPLE - if (-not (Test-InforcerConnection)) { Connect-Inforcer -ApiKey $key -Region uk } + if (-not (Test-InforcerConnection)) { Connect-Inforcer -ClientId $clientId -ClientSecret $secret -Region uk } Reconnects only when the current session is dead. .OUTPUTS System.Boolean. $true when the API responded, $false when it did not or there is no session. @@ -27,27 +27,22 @@ function Test-InforcerConnection { param() if (-not (Test-InforcerSession)) { - Write-Warning 'Not connected. To connect, run: Connect-Inforcer -ApiKey -Region ' + Write-Warning 'Not connected. To connect, run: Connect-Inforcer -ClientId -ClientSecret -Region ' return $false } -$uri = $script:InforcerSession.BaseUrl + '/beta/baselines' -$apiKey = ConvertFrom-InforcerSecureString -SecureString $script:InforcerSession.ApiKey +Write-Verbose "Testing connection to $(Get-InforcerBaseUrl -Endpoint '/baselines')..." -Write-Verbose 'Testing connection...' -Write-Verbose "URI: $uri" -Write-Verbose "API Key (first 8 chars): $($apiKey.Substring(0, [Math]::Min(8, $apiKey.Length)))..." -Write-Verbose "API Key Length: $($apiKey.Length)" - -try { - $headers = @{ 'Inf-Api-Key' = $apiKey; 'Accept' = 'application/json' } - $null = Invoke-RestMethod -Uri $uri -Method GET -Headers $headers -UseBasicParsing +$requestErrors = $null +$null = Invoke-InforcerApiRequest -Endpoint '/baselines' -Method GET -ErrorAction SilentlyContinue -ErrorVariable requestErrors +if (-not $requestErrors) { Write-Host 'SUCCESS! Connection is working.' -ForegroundColor Green return $true -} catch { - Write-Host 'FAILED! Connection test failed.' -ForegroundColor Red - Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red - Write-Warning "Inforcer connection test failed: $($_.Exception.Message)" - return $false } + +$reason = $requestErrors[0].Exception.Message +Write-Host 'FAILED! Connection test failed.' -ForegroundColor Red +Write-Host "Error: $reason" -ForegroundColor Red +Write-Warning "Inforcer connection test failed: $reason" +return $false } From 92497bcdd7ff3b80a98f43cf555df8e6b82506b1 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:09:38 +0100 Subject: [PATCH 02/27] feat: support v1 for users, groups, roles, secure score, policies and audit events - Paging goes through Invoke-InforcerPagedRequest in both modes - Get-InforcerGroup -Group attaches v1 members from the paged members endpoint - Get-InforcerTenantPolicies warns when v1 returns no Intune policies - v1 audit search without -EventType no longer sends beta-only event types - -TenantId accepts the MsTenantId alias for pipeline binding - fix: Get-InforcerUser -OutputType JsonObject no longer throws - fix: Get-InforcerSupportedEventType returns the live list instead of the built-in fallback (a ?. expression parsed as a variable named $n?) Co-Authored-By: Claude Opus 5.5 --- Tests/V1.Directory.Tests.ps1 | 388 ++++++++++++++++++ module/Private/Resolve-InforcerGroupId.ps1 | 19 +- module/Public/Get-InforcerAuditEvent.ps1 | 94 ++--- module/Public/Get-InforcerGroup.ps1 | 99 ++--- module/Public/Get-InforcerRole.ps1 | 8 +- module/Public/Get-InforcerSecureScore.ps1 | 10 +- .../Public/Get-InforcerSupportedEventType.ps1 | 12 +- module/Public/Get-InforcerTenantPolicies.ps1 | 30 +- module/Public/Get-InforcerUser.ps1 | 77 +--- 9 files changed, 524 insertions(+), 213 deletions(-) create mode 100644 Tests/V1.Directory.Tests.ps1 diff --git a/Tests/V1.Directory.Tests.ps1 b/Tests/V1.Directory.Tests.ps1 new file mode 100644 index 0000000..fc23eb0 --- /dev/null +++ b/Tests/V1.Directory.Tests.ps1 @@ -0,0 +1,388 @@ +# V1.Directory.Tests.ps1 +# Users, groups, roles, secure score, policies and audit events on beta (API key) and v1 (client credentials). +# Fixtures are anonymised (zero GUIDs, example names); no network calls. + +BeforeAll { + Remove-Module -Name 'InforcerCommunity' -ErrorAction SilentlyContinue + Import-Module (Join-Path $PSScriptRoot '../module/InforcerCommunity.psd1') -Force + $script:M = Get-Module InforcerCommunity + + $script:Tid = '00000000-0000-0000-0000-000000000001' + $script:G1 = '00000000-0000-0000-0000-0000000000a1' + + function global:New-DirResponse { + param([string]$Json, [int]$Status = 200) + [pscustomobject]@{ StatusCode = $Status; Body = ($Json | ConvertFrom-Json -Depth 100); Headers = @{} } + } + + function global:Set-DirSession { + param([switch]$Beta) + & (Get-Module InforcerCommunity) { + param($Beta) + $script:InforcerTenantListCache = $null + if ($Beta) { + $script:InforcerSession = @{ + AuthMode = 'ApiKey'; ApiVersion = 'beta'; ApiHost = 'https://api.example.invalid' + BaseUrl = 'https://api.example.invalid/api'; Region = 'uk'; ConnectedAt = Get-Date + ApiKey = (ConvertTo-SecureString 'beta-test-key-value' -AsPlainText -Force) + } + } else { + $script:InforcerSession = @{ + AuthMode = 'ClientCredentials'; ApiVersion = 'v1'; ApiHost = 'https://api.example.invalid' + BaseUrl = 'https://api.example.invalid'; Region = 'uk'; ConnectedAt = Get-Date + ClientId = 'client-id-example' + ClientSecret = (ConvertTo-SecureString 'client-secret-example-value' -AsPlainText -Force) + AccessToken = (ConvertTo-SecureString 'access-token-example-value' -AsPlainText -Force) + TokenExpiresAt = [datetime]::UtcNow.AddMinutes(50) + } + } + } $Beta.IsPresent + } + + # Routes every request through $global:DirRoute (param Uri, Method, Body) and records it in $global:DirCalls. + function global:Set-DirRoute { + param([scriptblock]$Route) + $global:DirCalls = [System.Collections.Generic.List[object]]::new() + $global:DirRoute = $Route + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { + $global:DirCalls.Add([pscustomobject]@{ Uri = [string]$Request.Uri; Method = $Request.Method; Body = $Request.Body }) + & $global:DirRoute ([string]$Request.Uri) $Request.Method $Request.Body + } + } +} + +AfterAll { + foreach ($f in 'New-DirResponse', 'Set-DirSession', 'Set-DirRoute') { Remove-Item "function:global:$f" -ErrorAction SilentlyContinue } + Remove-Variable -Name DirCalls, DirRoute -Scope Global -ErrorAction SilentlyContinue + & (Get-Module InforcerCommunity) { $script:InforcerSession = $null; $script:InforcerTenantListCache = $null } +} + +Describe 'Get-InforcerUser' { + BeforeAll { + $script:UserRoute = { + param($Uri, $Method, $Body) + if ($Uri -match 'continuationToken=tok1') { + New-DirResponse '{"users":[{"id":"u3","displayName":"User Three","groupCount":0,"roleCount":0}],"userCount":3}' + } elseif ($Uri -match '/users$|/users\?') { + New-DirResponse '{"users":[{"id":"u1","displayName":"User One","groupCount":2,"roleCount":1},{"id":"u2","displayName":"User Two","groupCount":0,"roleCount":0}],"userCount":3,"continuationToken":"tok1"}' + } else { New-DirResponse '{"title":"Not found","status":404}' 404 } + } + } + + It 'pages across v1 pages and maps groupCount/roleCount to groups/roles' { + Set-DirSession + Set-DirRoute $script:UserRoute + $u = @(Get-InforcerUser -TenantId $script:Tid) + $u.Count | Should -Be 3 + $u[0].groups | Should -Be 2 + $u[0].roles | Should -Be 1 + $global:DirCalls.Count | Should -Be 2 + $global:DirCalls[0].Uri | Should -BeLike 'https://api.example.invalid/v1/tenants/*/users' + $global:DirCalls[1].Uri | Should -BeLike '*continuationToken=tok1' + } + + It 'returns a JSON array on v1 -OutputType JsonObject (empty-list bug fixed)' { + Set-DirSession + Set-DirRoute $script:UserRoute + $json = Get-InforcerUser -TenantId $script:Tid -OutputType JsonObject + $json.TrimStart() | Should -BeLike '[[]*' + @($json | ConvertFrom-Json).Count | Should -Be 3 + } + + It 'returns a JSON array on beta -OutputType JsonObject without throwing' { + Set-DirSession -Beta + Set-DirRoute { + param($Uri, $Method, $Body) + New-DirResponse '{"success":true,"data":[{"id":"u1","displayName":"User One"},{"id":"u2","displayName":"User Two"}]}' + } + $json = Get-InforcerUser -TenantId 5 -OutputType JsonObject + $json.TrimStart() | Should -BeLike '[[]*' + @($json | ConvertFrom-Json).Count | Should -Be 2 + $global:DirCalls[0].Uri | Should -BeLike 'https://api.example.invalid/api/beta/tenants/5/users' + } + + It 'returns [] for an empty user list' { + Set-DirSession + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"users":[],"userCount":0}' } + (Get-InforcerUser -TenantId $script:Tid -OutputType JsonObject).Trim() | Should -Be '[]' + } + + It 'stops at MaxResults in the middle of a page without a second request' { + Set-DirSession + Set-DirRoute $script:UserRoute + @(Get-InforcerUser -TenantId $script:Tid -MaxResults 1).Count | Should -Be 1 + $global:DirCalls.Count | Should -Be 1 + } + + It 'stops when the API repeats a continuationToken' { + Set-DirSession + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"users":[{"id":"u1"}],"continuationToken":"same"}' } + @(Get-InforcerUser -TenantId $script:Tid).Count | Should -Be 2 + $global:DirCalls.Count | Should -Be 2 + } + + It 'sends -Search as a query parameter' { + Set-DirSession + Set-DirRoute $script:UserRoute + $null = Get-InforcerUser -TenantId $script:Tid -Search 'a b' -MaxResults 1 + $global:DirCalls[0].Uri | Should -BeLike '*users?search=a%20b' + } + + It 'refuses a numeric tenant id on v1 with InvalidTenantId' { + Set-DirSession + Set-DirRoute $script:UserRoute + { Get-InforcerUser -TenantId 5 -ErrorAction Stop } | Should -Throw '*API key*' + $global:DirCalls.Count | Should -Be 0 + } +} + +Describe 'Get-InforcerGroup' { + BeforeAll { + $script:GroupRoute = { + param($Uri, $Method, $Body) + if ($Uri -match '/groups/[^/?]+/members') { + if ($Uri -match 'continuationToken=m1') { New-DirResponse '{"members":[{"id":"m3","displayName":"Member Three","type":"user"}],"memberCount":3}' } + else { New-DirResponse '{"members":[{"id":"m1","displayName":"Member One","type":"user"},{"id":"m2","displayName":"Member Two","type":"group"}],"memberCount":3,"continuationToken":"m1"}' } + } elseif ($Uri -match '/groups/[^/?]+$') { + New-DirResponse '{"id":"00000000-0000-0000-0000-0000000000a1","displayName":"Group One","memberCount":3}' + } elseif ($Uri -match 'search=') { + New-DirResponse '{"groups":[{"id":"00000000-0000-0000-0000-0000000000a1","displayName":"Group One"},{"id":"00000000-0000-0000-0000-0000000000a2","displayName":"Group One Extra"}],"groupCount":2}' + } elseif ($Uri -match 'continuationToken=g1') { + New-DirResponse '{"groups":[{"id":"g3","displayName":"Finance"}],"groupCount":3}' + } else { + New-DirResponse '{"groups":[{"id":"g1","displayName":"Sales"},{"id":"g2","displayName":"Finance EU"}],"groupCount":3,"continuationToken":"g1"}' + } + } + } + + It 'lists groups across v1 pages' { + Set-DirSession + Set-DirRoute $script:GroupRoute + @(Get-InforcerGroup -TenantId $script:Tid).Count | Should -Be 3 + } + + It 'applies -Filter before -MaxResults across pages' { + Set-DirSession + Set-DirRoute $script:GroupRoute + $g = @(Get-InforcerGroup -TenantId $script:Tid -Filter 'Fin*' -MaxResults 2) + $g.Count | Should -Be 2 + $g[0].displayName | Should -Be 'Finance EU' + $g[1].displayName | Should -Be 'Finance' + } + + It 'returns a JSON array for the v1 list' { + Set-DirSession + Set-DirRoute $script:GroupRoute + (Get-InforcerGroup -TenantId $script:Tid -OutputType JsonObject).TrimStart() | Should -BeLike '[[]*' + } + + It 'keeps beta group list JSON unchanged: one group is a bare object' { + Set-DirSession -Beta + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"success":true,"data":[{"id":"g1","displayName":"Sales"}]}' } + (Get-InforcerGroup -TenantId 5 -OutputType JsonObject).TrimStart() | Should -BeLike '{*' + } + + It 'attaches the paged members to a v1 group detail' { + Set-DirSession + Set-DirRoute $script:GroupRoute + $g = Get-InforcerGroup -TenantId $script:Tid -Group $script:G1 + @($g.members).Count | Should -Be 3 + $g.members[2].id | Should -Be 'm3' + ($global:DirCalls | Where-Object Uri -like '*/v1/tenants/*/groups/*/members*').Count | Should -Be 2 + } + + It 'includes members in the v1 JSON detail' { + Set-DirSession + Set-DirRoute $script:GroupRoute + $o = Get-InforcerGroup -TenantId $script:Tid -Group $script:G1 -OutputType JsonObject | ConvertFrom-Json + @($o.members).Count | Should -Be 3 + } + + It 'resolves a group name on v1 by exact match' { + Set-DirSession + Set-DirRoute $script:GroupRoute + $g = Get-InforcerGroup -TenantId $script:Tid -Group 'Group One' + $g.id | Should -Be $script:G1 + $global:DirCalls[0].Uri | Should -BeLike '*groups?search=Group%20One' + } + + It 'resolves a group name on beta from .data and fetches no members' { + Set-DirSession -Beta + Set-DirRoute { + param($Uri, $Method, $Body) + if ($Uri -match 'search=') { New-DirResponse '{"success":true,"data":[{"id":"00000000-0000-0000-0000-0000000000a1","displayName":"Group One"}]}' } + else { New-DirResponse '{"success":true,"data":{"id":"00000000-0000-0000-0000-0000000000a1","displayName":"Group One","members":[{"id":"m1"}]}}' } + } + $g = Get-InforcerGroup -TenantId 7 -Group 'Group One' + $g.id | Should -Be $script:G1 + @($g.members).Count | Should -Be 1 + ($global:DirCalls | Where-Object Uri -like '*/members*').Count | Should -Be 0 + $global:DirCalls[0].Uri | Should -BeLike '*/api/beta/tenants/7/groups?search=*' + } + + It 'throws a clear error for an unknown group name' { + Set-DirSession + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"groups":[],"groupCount":0}' } + { Get-InforcerGroup -TenantId $script:Tid -Group 'Nope' -ErrorAction Stop } | Should -Throw '*No group found*' + } +} + +Describe 'Get-InforcerRole, Get-InforcerSecureScore, Get-InforcerSupportedEventType' { + It 'reads v1 roles from /directoryRoles' { + Set-DirSession + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"roles":[{"id":"r1","displayName":"Role One","isPrivileged":true},{"id":"r2","displayName":"Role Two","isPrivileged":false}]}' } + $r = @(Get-InforcerRole -TenantId $script:Tid) + $r.Count | Should -Be 2 + $global:DirCalls[0].Uri | Should -BeLike '*/v1/tenants/*/directoryRoles' + } + + It 'reads beta roles from /beta/.../roles' { + Set-DirSession -Beta + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"success":true,"data":[{"id":"r1","displayName":"Role One"},{"id":"r2","displayName":"Role Two"}]}' } + @(Get-InforcerRole -TenantId 5).Count | Should -Be 2 + $global:DirCalls[0].Uri | Should -BeLike '*/api/beta/tenants/5/roles' + } + + It 'returns the whole v1 secure score object' { + Set-DirSession + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"currentScore":42,"maxScore":100,"scores":[{"date":"2000-01-01","score":40}],"controlProfiles":[{"id":"c1"}]}' } + $s = Get-InforcerSecureScore -TenantId $script:Tid + $s.currentScore | Should -Be 42 + @($s.scores).Count | Should -Be 1 + $s.PSObject.TypeNames[0] | Should -Be 'InforcerCommunity.SecureScore' + } + + It 'reads v1 event types from /auditEvents/types' { + Set-DirSession + $saved = $global:InforcerCachedEventTypes + try { + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"eventTypes":[{"name":"authentication"},{"name":"userCreate"}]}' } + (@(Get-InforcerSupportedEventType) -join ',') | Should -Be 'authentication,userCreate' + $global:DirCalls[0].Uri | Should -BeLike '*/v1/auditEvents/types' + } finally { $global:InforcerCachedEventTypes = $saved } + } +} + +Describe 'Get-InforcerAuditEvent' { + BeforeAll { + $script:AuditRoute = { + param($Uri, $Method, $Body) + $b = $Body | ConvertFrom-Json + if ($b.continuationToken -eq 'a1') { + New-DirResponse '{"auditEvents":[{"id":"e3","eventType":"authentication","correlationId":"c1"}]}' + } else { + New-DirResponse '{"auditEvents":[{"id":"e1","eventType":"authentication","metadata":{"nameLookup":{"user:username:1":"user@example.invalid","user:displayName:1":"Example User"}}},{"id":"e2","eventType":"authentication"}],"continuationToken":"a1"}' + } + } + } + + It 'posts whole-second dates on v1, pages via the body token and reads auditEvents' { + Set-DirSession + Set-DirRoute $script:AuditRoute + $e = @(Get-InforcerAuditEvent -EventType authentication -DateFrom ([datetime]'2000-01-01') -DateTo ([datetime]'2000-01-02')) + $e.Count | Should -Be 3 + $global:DirCalls.Count | Should -Be 2 + $global:DirCalls[0].Method | Should -Be 'POST' + $global:DirCalls[0].Uri | Should -BeLike '*/v1/auditEvents/search' + $global:DirCalls[0].Body | Should -Match '"dateTo":"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ"' + ($global:DirCalls[1].Body | ConvertFrom-Json).continuationToken | Should -Be 'a1' + } + + It 'enriches v1 events with and without metadata' { + Set-DirSession + Set-DirRoute $script:AuditRoute + $e = @(Get-InforcerAuditEvent -EventType authentication -DateFrom ([datetime]'2000-01-01') -DateTo ([datetime]'2000-01-02')) + $e[0].UserName | Should -Be 'user@example.invalid' + $e[0].UserDisplayName | Should -Be 'Example User' + $e[1].PSObject.Properties['UserName'] | Should -BeNullOrEmpty + } + + It 'keeps milliseconds in the beta dateTo and reads items from .data' { + Set-DirSession -Beta + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"success":true,"data":{"items":[{"id":"e1","eventType":"authentication"}],"continuationToken":null}}' } + $e = @(Get-InforcerAuditEvent -EventType authentication -DateFrom ([datetime]'2000-01-01') -DateTo ([datetime]'2000-01-02')) + $e.Count | Should -Be 1 + $global:DirCalls[0].Uri | Should -BeLike '*/api/beta/auditEvents/search' + $global:DirCalls[0].Body | Should -Match '"dateTo":"[^"]*\.\d{3}Z"' + } + + It 'honours MaxResults and returns a JSON array' { + Set-DirSession + Set-DirRoute $script:AuditRoute + $json = Get-InforcerAuditEvent -EventType authentication -MaxResults 1 -OutputType JsonObject + $json.TrimStart() | Should -BeLike '[[]*' + @($json | ConvertFrom-Json).Count | Should -Be 1 + $global:DirCalls.Count | Should -Be 1 + } + + It 'omits eventTypes on v1 when -EventType is not given and makes no event-type lookup' { + Set-DirSession + Set-DirRoute $script:AuditRoute + $null = Get-InforcerAuditEvent -DateFrom ([datetime]'2000-01-01') -DateTo ([datetime]'2000-01-02') + $global:DirCalls.Count | Should -Be 2 + $global:DirCalls[0].Uri | Should -BeLike '*/v1/auditEvents/search' + $global:DirCalls[0].Body | Should -Not -Match 'eventTypes' + } + + It 'keeps beta JSON output unchanged: one event is a bare object' { + Set-DirSession -Beta + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"success":true,"data":{"items":[{"id":"e1","eventType":"authentication"}]}}' } + $json = Get-InforcerAuditEvent -EventType authentication -OutputType JsonObject + $json.TrimStart() | Should -BeLike '{*' + } + + It 'filters by -CorrelationId client-side' { + Set-DirSession + Set-DirRoute $script:AuditRoute + $e = @(Get-InforcerAuditEvent -EventType authentication -CorrelationId c1) + $e.Count | Should -Be 1 + $e[0].id | Should -Be 'e3' + } +} + +Describe 'Get-InforcerTenantPolicies' { + It 'warns once on v1 when no Intune policies are returned' { + Set-DirSession + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"policies":[{"id":"p1","displayName":"Policy One","product":"Entra","policyTypeName":"Conditional Access"}],"failedPolicyTypes":{}}' } + $p = @(Get-InforcerTenantPolicies -TenantId $script:Tid -WarningVariable w -WarningAction SilentlyContinue) + $p.Count | Should -Be 1 + @($w).Count | Should -Be 1 + "$($w[0])" | Should -BeLike '*Intune*known gap*inforcer*' + $p[0].PolicyName | Should -Be 'Policy One' + } + + It 'does not warn on v1 when an Intune policy is present' { + Set-DirSession + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"policies":[{"id":"p1","displayName":"Policy One","product":"Intune"}],"failedPolicyTypes":{}}' } + $null = Get-InforcerTenantPolicies -TenantId $script:Tid -WarningVariable w -WarningAction SilentlyContinue + @($w).Count | Should -Be 0 + } + + It 'returns a JSON array on v1, also for a single policy, and still warns' { + Set-DirSession + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"policies":[{"id":"p1","displayName":"Policy One","product":"Entra"}],"failedPolicyTypes":{}}' } + $json = Get-InforcerTenantPolicies -TenantId $script:Tid -OutputType JsonObject -WarningVariable w -WarningAction SilentlyContinue + $json.TrimStart() | Should -BeLike '[[]*' + @($w).Count | Should -Be 1 + } + + It 'leaves beta output and warnings unchanged' { + Set-DirSession -Beta + Set-DirRoute { param($Uri, $Method, $Body) New-DirResponse '{"success":true,"data":[{"id":"p1","displayName":"Policy One","product":"Entra"},{"id":"p2","name":"Policy Two","product":"Entra"}]}' } + $p = @(Get-InforcerTenantPolicies -TenantId 5 -WarningVariable w -WarningAction SilentlyContinue) + $p.Count | Should -Be 2 + @($w).Count | Should -Be 0 + $global:DirCalls[0].Uri | Should -BeLike '*/api/beta/tenants/5/policies' + } +} + +Describe 'MsTenantId alias' { + It 'is defined next to ClientTenantId on every -TenantId parameter' -ForEach @( + @{ Name = 'Get-InforcerUser' }, @{ Name = 'Get-InforcerGroup' }, @{ Name = 'Get-InforcerRole' }, + @{ Name = 'Get-InforcerSecureScore' }, @{ Name = 'Get-InforcerTenantPolicies' } + ) { + $aliases = (Get-Command $Name).Parameters['TenantId'].Aliases + $aliases | Should -Contain 'ClientTenantId' + $aliases | Should -Contain 'MsTenantId' + } +} diff --git a/module/Private/Resolve-InforcerGroupId.ps1 b/module/Private/Resolve-InforcerGroupId.ps1 index e2a61c8..913f014 100644 --- a/module/Private/Resolve-InforcerGroupId.ps1 +++ b/module/Private/Resolve-InforcerGroupId.ps1 @@ -9,7 +9,7 @@ function Resolve-InforcerGroupId { .PARAMETER GroupId Group GUID or display name. .PARAMETER TenantId - Resolved Inforcer tenant ID (numeric). Required for API lookup when resolving by name. + Resolved tenant ID (numeric on an API-key login, Microsoft tenant GUID string on client credentials). Required for API lookup when resolving by name. .OUTPUTS System.String - The resolved group GUID. #> @@ -20,7 +20,7 @@ function Resolve-InforcerGroupId { [string]$GroupId, [Parameter(Mandatory = $true)] - [int]$TenantId + [object]$TenantId ) process { @@ -33,26 +33,17 @@ function Resolve-InforcerGroupId { } Write-Verbose "Group name detected: '$groupIdString'. Searching for group..." - $endpoint = "/beta/tenants/$TenantId/groups?search=$([System.Uri]::EscapeDataString($groupIdString))" + $endpoint = "/tenants/$TenantId/groups?search=$([System.Uri]::EscapeDataString($groupIdString))" $apiErr = @() - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveFullResponse -ErrorVariable apiErr -ErrorAction SilentlyContinue + $items = @(Invoke-InforcerPagedRequest -Endpoint $endpoint -ObjectType Group -ErrorVariable apiErr -ErrorAction SilentlyContinue) - if ($null -eq $response) { + if ($items.Count -eq 0) { if ($apiErr.Count -gt 0) { throw [System.InvalidOperationException]::new("Failed to search for group '$groupIdString': $($apiErr[0].Exception.Message)") } throw [System.InvalidOperationException]::new("No group found with name: $groupIdString") } - $items = $null - if ($null -ne $response.PSObject.Properties['data']) { - $items = @($response.data) - } - - if ($null -eq $items -or $items.Count -eq 0) { - throw [System.InvalidOperationException]::new("No group found with name: $groupIdString") - } - $exactMatches = [System.Collections.Generic.List[string]]::new() $caseInsensitiveMatches = [System.Collections.Generic.List[string]]::new() foreach ($g in $items) { diff --git a/module/Public/Get-InforcerAuditEvent.ps1 b/module/Public/Get-InforcerAuditEvent.ps1 index 33f63f6..191d4ff 100644 --- a/module/Public/Get-InforcerAuditEvent.ps1 +++ b/module/Public/Get-InforcerAuditEvent.ps1 @@ -4,7 +4,7 @@ Required API scope(s): Audit.Read (unconfirmed — see docs/API-REFERENCE.md) .DESCRIPTION - POST /beta/auditEvents/search. When -DateFrom and -DateTo are omitted, uses a wide default range (10 years ago to now). + POST /auditEvents/search on both login types (API key: /beta, client credentials: /v1). With client credentials the response has no ClientId, Message, RelId or RelType fields. When -DateFrom and -DateTo are omitted, uses a wide default range (10 years ago to now). When -EventType is not specified, uses all event types from the API (or fallback authentication, failedAuthentication). Use -Id to retrieve a specific event by its event ID. Use -CorrelationId to retrieve all events sharing a correlation ID. Both perform a broad search and filter client-side. @@ -42,7 +42,7 @@ Get-InforcerAuditEvent -EventType authentication,failedAuthentication -DateFrom $from -DateTo $to .EXAMPLE Get-InforcerAuditEvent -OutputType JsonObject - Returns a JSON array string; output objects do not include a metadata property (flattened to top-level fields). + Returns a JSON string (a JSON array on v1 even for zero or one event); output objects do not include a metadata property (flattened to top-level fields). .OUTPUTS PSObject or String (JSON array when -OutputType JsonObject) .LINK @@ -122,8 +122,12 @@ if (-not (Test-InforcerSession)) { } # Resolve event types +$isV1 = $script:InforcerSession.ApiVersion -eq 'v1' $eventTypes = @() if ($null -eq $EventType -or $EventType.Count -eq 0) { + if ($isV1) { + Write-Verbose 'v1: no -EventType given; omitting eventTypes so the API searches all types.' + } else { Write-Verbose 'Fetching all audit event types from API...' try { $eventTypes = @(Get-InforcerSupportedEventType -ErrorAction SilentlyContinue) @@ -136,6 +140,7 @@ if ($null -eq $EventType -or $EventType.Count -eq 0) { } else { Write-Verbose "Using all $($eventTypes.Count) event type(s)." } + } } else { $eventTypes = @($EventType) } @@ -150,71 +155,48 @@ if (-not $dateFromVal -and -not $dateToVal) { $dateFromVal = $dateToVal.AddYears(-10) } $dateFromStr = $dateFromVal.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -$dateToStr = $dateToVal.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') +$dateToStr = $dateToVal.ToUniversalTime().ToString($(if ($isV1) { 'yyyy-MM-ddTHH:mm:ssZ' } else { 'yyyy-MM-ddTHH:mm:ss.fffZ' })) -$allItems = [System.Collections.ArrayList]::new() +$allItems = [System.Collections.Generic.List[object]]::new() $hasLimit = $MaxResults -gt 0 $batchSize = 10 + $typeBatches = [System.Collections.Generic.List[object]]::new() -for ($i = 0; $i -lt $eventTypes.Count; $i += $batchSize) { - $end = [Math]::Min($i + $batchSize, $eventTypes.Count) - [void]$typeBatches.Add(@($eventTypes[$i..($end - 1)])) +if ($eventTypes.Count -eq 0) { + $typeBatches.Add($null) +} else { + for ($i = 0; $i -lt $eventTypes.Count; $i += $batchSize) { + $end = [Math]::Min($i + $batchSize, $eventTypes.Count) + $typeBatches.Add(@($eventTypes[$i..($end - 1)])) + } } foreach ($batch in $typeBatches) { - if ($hasLimit -and $allItems.Count -ge $MaxResults) { break } - $continuationToken = $null - do { - $bodyObj = @{ - eventTypes = @($batch) - dateFrom = $dateFromStr - dateTo = $dateToStr - pageSize = $PageSize - } - if ($User) { - $bodyObj['user'] = $User - } - if ($continuationToken) { - $bodyObj['continuationToken'] = $continuationToken - } - $body = $bodyObj | ConvertTo-Json -Depth 100 -Compress - - Write-Verbose 'Searching audit events...' - $response = Invoke-InforcerApiRequest -Endpoint '/beta/auditEvents/search' -Method POST -Body $body -OutputType PowerShellObject -PreserveStructure - if ($null -eq $response) { break } - - # After -PreserveStructure, .data is already unwrapped but inner structure (items + continuationToken) is intact - $root = $response - $items = $null - foreach ($propName in 'items', 'Items') { - $prop = $root.PSObject.Properties[$propName] - if ($prop -and $null -ne $prop.Value) { $items = $prop.Value; break } - } - - if ($items) { - foreach ($item in @($items)) { - if ($item -is [PSObject]) { - $null = Add-InforcerPropertyAliases -InputObject $item -ObjectType AuditEvent - $item.PSObject.TypeNames.Insert(0, 'InforcerCommunity.AuditEvent') - [void]$allItems.Add($item) - if ($hasLimit -and $allItems.Count -ge $MaxResults) { break } - } - } - } + $remaining = if ($hasLimit) { $MaxResults - $allItems.Count } else { 0 } + if ($hasLimit -and $remaining -le 0) { break } - $continuationToken = $null - foreach ($tokenProp in 'continuationToken', 'ContinuationToken') { - $tp = $root.PSObject.Properties[$tokenProp] - if ($tp -and $tp.Value) { $continuationToken = $tp.Value; break } + $bodyObj = @{ + dateFrom = $dateFromStr + dateTo = $dateToStr + pageSize = $PageSize + } + if ($batch) { $bodyObj['eventTypes'] = @($batch) } + if ($User) { $bodyObj['user'] = $User } + + Write-Verbose 'Searching audit events...' + foreach ($item in @(Invoke-InforcerPagedRequest -Endpoint '/auditEvents/search' -Method POST -Body $bodyObj -ObjectType AuditEvent -MaxResults $remaining)) { + if ($item -is [PSObject]) { + $null = Add-InforcerPropertyAliases -InputObject $item -ObjectType AuditEvent + $item.PSObject.TypeNames.Insert(0, 'InforcerCommunity.AuditEvent') + $allItems.Add($item) } - if ($hasLimit -and $allItems.Count -ge $MaxResults) { break } - } while ($continuationToken) + } } # Filter by -Id or -CorrelationId when specified if (-not [string]::IsNullOrWhiteSpace($Id)) { $idTrimmed = $Id.Trim() - $allItems = [System.Collections.ArrayList]@($allItems | Where-Object { + $allItems = [System.Collections.Generic.List[object]]@($allItems | Where-Object { $idProp = $_.PSObject.Properties['id'] $idProp -and $idProp.Value -and $idProp.Value.ToString() -eq $idTrimmed }) @@ -225,7 +207,7 @@ if (-not [string]::IsNullOrWhiteSpace($Id)) { } if (-not [string]::IsNullOrWhiteSpace($CorrelationId)) { $corrTrimmed = $CorrelationId.Trim() - $allItems = [System.Collections.ArrayList]@($allItems | Where-Object { + $allItems = [System.Collections.Generic.List[object]]@($allItems | Where-Object { $corrProp = $_.PSObject.Properties['correlationId'] $corrProp -and $corrProp.Value -and $corrProp.Value.ToString() -eq $corrTrimmed }) @@ -236,8 +218,8 @@ if (-not [string]::IsNullOrWhiteSpace($CorrelationId)) { } if ($OutputType -eq 'JsonObject') { - $json = $allItems | ConvertTo-Json -Depth 100 - return $json + if ($isV1) { return ConvertTo-Json -InputObject @($allItems) -Depth 100 } + return $allItems | ConvertTo-Json -Depth 100 } else { $allItems } diff --git a/module/Public/Get-InforcerGroup.ps1 b/module/Public/Get-InforcerGroup.ps1 index e6419cf..7e39210 100644 --- a/module/Public/Get-InforcerGroup.ps1 +++ b/module/Public/Get-InforcerGroup.ps1 @@ -10,6 +10,9 @@ function Get-InforcerGroup { When called without -Group, returns all groups (GroupSummary objects) with optional filtering and auto-pagination. When called with -Group, resolves the group by GUID or display name and returns the full group detail (Group object) including members. + Works with both login types. With client credentials the members of a single group come from a separate + paged members call and are attached as the Members property, so the output matches an API-key login. + Use -Search for server-side prefix filtering (fast, sent to API). Use -Filter for client-side wildcard filtering (supports contains, e.g. *comp*). @@ -19,7 +22,7 @@ function Get-InforcerGroup { rule requires a -Group call per group. .PARAMETER TenantId - The Inforcer tenant ID. Accepts numeric ID, GUID, or tenant name. Supports pipeline input. + The Inforcer tenant ID. Accepts numeric ID (API-key login only), Microsoft tenant GUID, or tenant name. Supports pipeline input. .PARAMETER Group The group to retrieve full details for. Accepts a GUID or a display name. @@ -96,7 +99,7 @@ function Get-InforcerGroup { param( [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'List')] [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ById')] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, [Parameter(Mandatory, ParameterSetName = 'ById')] @@ -140,9 +143,9 @@ function Get-InforcerGroup { return } - $endpoint = "/beta/tenants/$resolvedTenantId/groups/$resolvedGroupId" + $endpoint = "/tenants/$resolvedTenantId/groups/$resolvedGroupId" $err = $null - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure -ErrorVariable err -ErrorAction SilentlyContinue + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure -ObjectType Group -ErrorVariable err -ErrorAction SilentlyContinue if ($null -eq $response) { if ($err -and $err[0].FullyQualifiedErrorId -like 'ApiRequestFailed_404*') { @@ -155,6 +158,11 @@ function Get-InforcerGroup { return } + if ($script:InforcerSession.ApiVersion -eq 'v1') { + $members = @(Invoke-InforcerPagedRequest -Endpoint "$endpoint/members" -ObjectType GroupMember) + Add-Member -InputObject $response -NotePropertyName 'members' -NotePropertyValue $members -Force + } + if ($OutputType -eq 'JsonObject') { return $response | ConvertTo-Json -Depth 100 } @@ -165,69 +173,30 @@ function Get-InforcerGroup { } # --- List: paginated group list --- - $jsonBuffer = $null - if ($OutputType -eq 'JsonObject') { $jsonBuffer = [System.Collections.ArrayList]::new() } - $continuationToken = $null - $pageCount = 0 - $itemCount = 0 - - do { - $pageCount++ - $endpoint = "/beta/tenants/$resolvedTenantId/groups" - $queryParams = @() - if ($Search) { $queryParams += "search=$([System.Uri]::EscapeDataString($Search))" } - if ($continuationToken) { $queryParams += "continuationToken=$([System.Uri]::EscapeDataString($continuationToken))" } - if ($queryParams.Count -gt 0) { $endpoint += '?' + ($queryParams -join '&') } - - Write-Verbose "Fetching page $pageCount..." - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveFullResponse - - if ($null -eq $response) { break } - - # continuationToken is at the response root level (sibling of .data), not inside .data - $items = if ($null -ne $response.PSObject.Properties['data']) { $response.data } else { $null } - $newToken = if ($null -ne $response.PSObject.Properties['continuationToken']) { $response.continuationToken } else { $null } - - # Guard against infinite loop: if API returns the same token, stop - if ($newToken -and $newToken -eq $continuationToken) { - Write-Verbose "API returned duplicate continuationToken. Stopping pagination." - break - } - $continuationToken = $newToken - - if ($null -ne $items) { - $itemArray = @($items) - Write-Verbose "Page $pageCount returned $($itemArray.Count) items." - foreach ($item in $itemArray) { - if ($null -eq $item) { continue } - if ($MaxResults -gt 0 -and $itemCount -ge $MaxResults) { - $continuationToken = $null - break - } - - # Client-side wildcard filter on displayName - if ($Filter) { - $displayName = $null - $dnProp = $item.PSObject.Properties['displayName'] - if ($dnProp) { $displayName = $dnProp.Value } - if (-not $displayName -or $displayName -notlike $Filter) { continue } - } - - $itemCount++ - - if ($OutputType -eq 'JsonObject') { - [void]$jsonBuffer.Add($item) - } else { - $null = Add-InforcerPropertyAliases -InputObject $item -ObjectType GroupSummary - $item.PSObject.TypeNames.Insert(0, 'InforcerCommunity.GroupSummary') - $item - } - } - } - } while ($continuationToken) + $endpoint = "/tenants/$resolvedTenantId/groups" + if ($Search) { $endpoint += '?search=' + [System.Uri]::EscapeDataString($Search) } + + # -MaxResults counts matches, so with -Filter every page is read before the limit applies. + $pagedMax = if ($Filter) { 0 } else { $MaxResults } + $groups = @(Invoke-InforcerPagedRequest -Endpoint $endpoint -ObjectType Group -MaxResults $pagedMax) + + if ($Filter) { + $groups = @($groups | Where-Object { + $dn = $_.PSObject.Properties['displayName'].Value + $dn -and $dn -like $Filter + }) + if ($MaxResults -gt 0 -and $groups.Count -gt $MaxResults) { $groups = $groups[0..($MaxResults - 1)] } + } if ($OutputType -eq 'JsonObject') { - return $jsonBuffer | ConvertTo-Json -Depth 100 + if ($script:InforcerSession.ApiVersion -eq 'v1') { return ConvertTo-Json -InputObject $groups -Depth 100 } + return $groups | ConvertTo-Json -Depth 100 + } + + foreach ($item in $groups) { + $null = Add-InforcerPropertyAliases -InputObject $item -ObjectType GroupSummary + $item.PSObject.TypeNames.Insert(0, 'InforcerCommunity.GroupSummary') + $item } } } diff --git a/module/Public/Get-InforcerRole.ps1 b/module/Public/Get-InforcerRole.ps1 index b0e6be8..8ddc7ce 100644 --- a/module/Public/Get-InforcerRole.ps1 +++ b/module/Public/Get-InforcerRole.ps1 @@ -11,7 +11,7 @@ function Get-InforcerRole { built-in, enabled, or privileged. .PARAMETER TenantId - The Inforcer tenant ID. Accepts numeric ID, GUID, or tenant name. Supports pipeline input. + The Inforcer tenant ID. Accepts numeric ID (API-key login only), Microsoft tenant GUID, or tenant name. Supports pipeline input. .PARAMETER OutputType Output type: 'PowerShellObject' (default) or 'JsonObject'. @@ -49,7 +49,7 @@ function Get-InforcerRole { [OutputType([PSObject], [string])] param( [Parameter(Mandatory, ValueFromPipelineByPropertyName)] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, [Parameter(Mandatory = $false)] @@ -70,10 +70,10 @@ function Get-InforcerRole { return } - $endpoint = "/beta/tenants/$resolvedTenantId/roles" + $endpoint = "/tenants/$resolvedTenantId/roles" Write-Verbose "Retrieving role definitions for tenant $resolvedTenantId..." - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -ObjectType Role if ($null -eq $response) { return } $roleData = @($response) if ($roleData.Count -eq 0) { return } diff --git a/module/Public/Get-InforcerSecureScore.ps1 b/module/Public/Get-InforcerSecureScore.ps1 index 4bf8e4f..4e0c8b9 100644 --- a/module/Public/Get-InforcerSecureScore.ps1 +++ b/module/Public/Get-InforcerSecureScore.ps1 @@ -6,13 +6,13 @@ function Get-InforcerSecureScore { Required API scope(s): Tenants.SecureScores.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) .DESCRIPTION - Gets secure score data for a tenant from GET /beta/tenants/{tenantId}/secureScores. + Gets secure score data for a tenant from GET /tenants/{tenantId}/secureScores (beta or v1, by login type). Returns the current score, max score, licensed user count, enabled services, up to 90 days of daily score history, per-category scores, and actionable control profiles with remediation guidance. .PARAMETER TenantId - The Inforcer tenant ID. Accepts numeric ID, GUID, or tenant name. Supports pipeline input. + The Inforcer tenant ID. Accepts numeric ID (API-key login only), Microsoft tenant GUID, or tenant name. Supports pipeline input. .PARAMETER OutputType Output type: 'PowerShellObject' (default) or 'JsonObject'. @@ -70,7 +70,7 @@ function Get-InforcerSecureScore { [OutputType([PSObject], [string])] param( [Parameter(Mandatory, ValueFromPipelineByPropertyName)] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, [Parameter(Mandatory = $false)] @@ -91,7 +91,7 @@ function Get-InforcerSecureScore { return } - $endpoint = "/beta/tenants/$resolvedTenantId/secureScores" + $endpoint = "/tenants/$resolvedTenantId/secureScores" Write-Verbose "Retrieving secure score for tenant $resolvedTenantId..." # -PreserveStructure prevents Invoke-InforcerApiRequest from unwrapping the first array @@ -99,7 +99,7 @@ function Get-InforcerSecureScore { # content sits in its `scores`, `controlProfiles`, and `controlCategoryScores` arrays — # without -PreserveStructure the caller would receive one of those inner arrays instead # of the top-level tenantSecureScoreDetails object. - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure -ObjectType SecureScore if ($null -eq $response) { return } if ($OutputType -eq 'JsonObject') { diff --git a/module/Public/Get-InforcerSupportedEventType.ps1 b/module/Public/Get-InforcerSupportedEventType.ps1 index d04ab4e..982e2e8 100644 --- a/module/Public/Get-InforcerSupportedEventType.ps1 +++ b/module/Public/Get-InforcerSupportedEventType.ps1 @@ -92,7 +92,7 @@ function Get-InforcerSupportedEventType { } try { - $response = Invoke-InforcerApiRequest -Endpoint '/beta/auditEvents/eventTypes' -Method GET -OutputType PowerShellObject + $response = Invoke-InforcerApiRequest -Endpoint '/auditEvents/eventTypes' -Method GET -OutputType PowerShellObject -ObjectType AuditEventType if ($null -eq $response) { if ($global:InforcerCachedEventTypes -and $global:InforcerCachedEventTypes.Count -gt 0) { $global:InforcerCachedEventTypes | ForEach-Object { $_ } @@ -101,12 +101,14 @@ function Get-InforcerSupportedEventType { } function GetEventTypeName($o) { + if ($o -is [string]) { return $o } if ($o -is [PSObject]) { - $n = $o.PSObject.Properties['name'].Value - if (-not $n) { $n = $o.PSObject.Properties['Name'].Value } - return $n?.ToString() + $nameProp = $o.PSObject.Properties['name'] + if ($nameProp -and $null -ne $nameProp.Value) { return [string]$nameProp.Value } + return $null } - return $o?.ToString() + if ($null -ne $o) { return [string]$o } + return $null } function ResolveEventTypes($typesObj) { diff --git a/module/Public/Get-InforcerTenantPolicies.ps1 b/module/Public/Get-InforcerTenantPolicies.ps1 index 58921e7..49d7661 100644 --- a/module/Public/Get-InforcerTenantPolicies.ps1 +++ b/module/Public/Get-InforcerTenantPolicies.ps1 @@ -44,14 +44,16 @@ function EnrichPolicyObject { Required API scope(s): tenants.policies.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) .DESCRIPTION - Gets all policies for the specified tenant. TenantId accepts a numeric ID, GUID, or tenant name. + Gets all policies for the specified tenant. TenantId accepts a numeric ID (API-key login only), Microsoft tenant GUID, or tenant name. + Works with both login types. With client credentials (v1) the API currently returns no Intune policies; + when the result holds none, one warning says so. An API-key login still returns them. Output is normalized to use PolicyName (from displayName or name) so properties are consistent across all rows. .PARAMETER Format Raw (default). .PARAMETER TenantId - Tenant to get policies for (required). Numeric ID, GUID, or tenant name. + Tenant to get policies for (required). Numeric ID (API-key login only), Microsoft tenant GUID, or tenant name. .PARAMETER OutputType - PowerShellObject (default) or JsonObject. JSON output uses Depth 100. + PowerShellObject (default) or JsonObject. JSON output is an array and uses Depth 100. .EXAMPLE Get-InforcerTenantPolicies -TenantId 482 .EXAMPLE @@ -74,7 +76,7 @@ param( [string]$Format = 'Raw', [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, [Parameter(Mandatory = $false)] @@ -96,7 +98,25 @@ try { Write-Verbose "Retrieving policies for tenant ID: $clientTenantId" -$endpoint = "/beta/tenants/$clientTenantId/policies" +$endpoint = "/tenants/$clientTenantId/policies" + +if ($script:InforcerSession.ApiVersion -eq 'v1') { + $apiErr = $null + $policies = @(Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -ObjectType Policy -ErrorVariable apiErr) + if ($apiErr) { return } + + if (-not @($policies | Where-Object { $_.product -eq 'Intune' })) { + Write-Warning "The v1 API currently returns no Intune policies for this tenant (a known gap in inforcer's v1 API). Connecting with an API key still returns them." + } + if ($OutputType -eq 'JsonObject') { + return ConvertTo-Json -InputObject $policies -Depth 100 + } + foreach ($item in $policies) { + if ($item -is [PSObject]) { EnrichPolicyObject $item } + } + return $policies +} + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType $OutputType if ($null -eq $response) { return } diff --git a/module/Public/Get-InforcerUser.ps1 b/module/Public/Get-InforcerUser.ps1 index d4f28ba..3404a73 100644 --- a/module/Public/Get-InforcerUser.ps1 +++ b/module/Public/Get-InforcerUser.ps1 @@ -6,7 +6,7 @@ function Get-InforcerUser { Required API scope(s): Tenants.Users.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) .DESCRIPTION - Gets a list of users or a single user by ID from the Inforcer API. + Gets a list of users or a single user by ID from the Inforcer API. Works with both login types (API key and client credentials). When called without -UserId, returns all users (UserSummary objects) with optional search filtering and auto-pagination. When called with -UserId, returns the full user detail (User object) including groups, roles, devices, and risk information. @@ -14,7 +14,7 @@ function Get-InforcerUser { Output format. Currently only 'Raw' is supported. .PARAMETER TenantId - The Inforcer tenant ID. Accepts numeric ID, GUID, or tenant name. Supports pipeline input. + The Inforcer tenant ID. Accepts numeric ID (API-key login only), Microsoft tenant GUID, or tenant name. Supports pipeline input. .PARAMETER Search Server-side search filter for the user list. Only available in the List parameter set. @@ -26,7 +26,7 @@ function Get-InforcerUser { The user ID (GUID) to retrieve full details for. Only available in the ById parameter set. .PARAMETER OutputType - Output type: 'PowerShellObject' (default) or 'JsonObject'. + Output type: 'PowerShellObject' (default) or 'JsonObject'. Depth 100; the v1 list is always a JSON array. .EXAMPLE Get-InforcerUser -TenantId 139 @@ -68,7 +68,7 @@ function Get-InforcerUser { [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'List')] [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ById')] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, [Parameter(ParameterSetName = 'List')] @@ -101,12 +101,12 @@ function Get-InforcerUser { if ($PSCmdlet.ParameterSetName -eq 'ById') { # --- ById: single user detail --- - $endpoint = "/beta/tenants/$resolvedTenantId/users/$UserId" + $endpoint = "/tenants/$resolvedTenantId/users/$UserId" $err = $null # -PreserveStructure: user detail response is a single object with many nested arrays # (groups, roles, devices, appRoleAssignments, assignedLicenses, businessPhones, ...). # Without it, Invoke-InforcerApiRequest would unwrap to the first array property. - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure -ErrorVariable err -ErrorAction SilentlyContinue + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure -ObjectType User -ErrorVariable err -ErrorAction SilentlyContinue if ($null -eq $response) { # Only emit UserNotFound if API returned 404; otherwise the API helper already wrote the real error @@ -128,61 +128,20 @@ function Get-InforcerUser { } # --- List: paginated user list --- - $jsonBuffer = if ($OutputType -eq 'JsonObject') { [System.Collections.ArrayList]::new() } else { $null } - $continuationToken = $null - $pageCount = 0 - $itemCount = 0 - - do { - $pageCount++ - $endpoint = "/beta/tenants/$resolvedTenantId/users" - $queryParams = @() - if ($Search) { $queryParams += "search=$([System.Uri]::EscapeDataString($Search))" } - if ($continuationToken) { $queryParams += "continuationToken=$([System.Uri]::EscapeDataString($continuationToken))" } - if ($queryParams.Count -gt 0) { $endpoint += '?' + ($queryParams -join '&') } - - Write-Verbose "Fetching page $pageCount..." - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveFullResponse - - if ($null -eq $response) { break } - - # continuationToken is at the response root level (sibling of .data), not inside .data - $items = if ($null -ne $response.PSObject.Properties['data']) { $response.data } else { $null } - $newToken = if ($null -ne $response.PSObject.Properties['continuationToken']) { $response.continuationToken } else { $null } - - # Guard against infinite loop: if API returns the same token, stop - if ($newToken -and $newToken -eq $continuationToken) { - Write-Verbose "API returned duplicate continuationToken. Stopping pagination." - break - } - $continuationToken = $newToken - - if ($null -ne $items) { - $itemArray = @($items) - Write-Verbose "Page $pageCount returned $($itemArray.Count) items." - foreach ($item in $itemArray) { - if ($null -eq $item) { continue } - if ($MaxResults -gt 0 -and $itemCount -ge $MaxResults) { - $continuationToken = $null - break - } - $itemCount++ - - if ($OutputType -eq 'JsonObject') { - # Buffer raw items without aliases for clean JSON - [void]$jsonBuffer.Add($item) - } else { - # Stream to pipeline immediately - $null = Add-InforcerPropertyAliases -InputObject $item -ObjectType UserSummary - $item.PSObject.TypeNames.Insert(0, 'InforcerCommunity.UserSummary') - $item - } - } - } - } while ($continuationToken) + $endpoint = "/tenants/$resolvedTenantId/users" + if ($Search) { $endpoint += '?search=' + [System.Uri]::EscapeDataString($Search) } + + $users = @(Invoke-InforcerPagedRequest -Endpoint $endpoint -ObjectType User -MaxResults $MaxResults) if ($OutputType -eq 'JsonObject') { - return $jsonBuffer | ConvertTo-Json -Depth 100 + if ($script:InforcerSession.ApiVersion -eq 'v1') { return ConvertTo-Json -InputObject $users -Depth 100 } + return $users | ConvertTo-Json -Depth 100 + } + + foreach ($item in $users) { + $null = Add-InforcerPropertyAliases -InputObject $item -ObjectType UserSummary + $item.PSObject.TypeNames.Insert(0, 'InforcerCommunity.UserSummary') + $item } } } From c2fe8ec645decb5ce00d291a2eca95f2b4bbf9fc Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:17:11 +0100 Subject: [PATCH 03/27] feat: support v1 for assessments and reports - All report and assessment requests, including the assessment runspace, the output download, the run probe and the TAB completer, take their auth header from Get-InforcerAuthHeader; long polls fetch a fresh header per call - v1 report runs post msTenantIds; outputs map msTenantId to tenantId - Permission and scope hints name v1 API client permissions on a v1 login - Invoke-InforcerAssessment -MultiTenant finds tenants on v1 - -TenantId accepts the MsTenantId alias - fix: the assessment-ID completer returned nothing when -ReportType was unbound Co-Authored-By: Claude Opus 5.5 --- Tests/V1.Reports.Tests.ps1 | 657 ++++++++++++++++++ .../Private/Invoke-InforcerAssessmentRun.ps1 | 48 +- module/Private/Invoke-InforcerRawDownload.ps1 | 80 ++- .../Private/Resolve-InforcerAssessmentId.ps1 | 4 +- .../Resolve-InforcerReportTypeSchema.ps1 | 17 +- .../Test-InforcerReportRunTerminal.ps1 | 71 +- module/Public/Get-InforcerAssessment.ps1 | 6 +- module/Public/Get-InforcerReportRun.ps1 | 14 +- module/Public/Get-InforcerReportType.ps1 | 7 +- module/Public/Invoke-InforcerAssessment.ps1 | 34 +- module/Public/Invoke-InforcerReport.ps1 | 207 +++--- module/Public/Save-InforcerReportOutput.ps1 | 11 +- 12 files changed, 954 insertions(+), 202 deletions(-) create mode 100644 Tests/V1.Reports.Tests.ps1 diff --git a/Tests/V1.Reports.Tests.ps1 b/Tests/V1.Reports.Tests.ps1 new file mode 100644 index 0000000..2c5a4e4 --- /dev/null +++ b/Tests/V1.Reports.Tests.ps1 @@ -0,0 +1,657 @@ +# V1.Reports.Tests.ps1 +# Assessments and reports on both APIs: request bodies per version, header passing into the assessment +# runspace and the TAB completer, output mapping and the Collated sentinel, tenant-id validation. +# Fixtures are anonymised (zero GUIDs, example names). HTTP is mocked, or served by a loopback listener +# where the code under test calls Invoke-RestMethod from a separate runspace / the caller's scope. + +BeforeAll { + Remove-Module -Name 'InforcerCommunity' -ErrorAction SilentlyContinue + Import-Module (Join-Path $PSScriptRoot '../module/InforcerCommunity.psd1') -Force + $script:M = Get-Module InforcerCommunity + + $script:Tid1 = '00000000-0000-0000-0000-000000000001' + $script:Tid2 = '00000000-0000-0000-0000-000000000002' + $script:Run1 = '00000000-0000-0000-0000-0000000000a1' + + function global:Set-ReportsTestSession { + param([ValidateSet('v1', 'beta')][string]$Mode = 'v1', [string]$ApiHost = 'https://api.example.invalid') + & (Get-Module InforcerCommunity) { + param($Mode, $ApiHost) + $script:InforcerTenantListCache = $null + $script:InforcerAssessmentCache = $null + $script:InforcerAssessmentCacheDeniedAt = $null + $script:InforcerReportTypeCache = @( + [pscustomobject]@{ key = 'ActiveUserCount'; name = 'Active users'; collatable = $true; singleTenantOnly = $false + supportedOutputFormats = @('csv', 'json', 'html'); requiredParameters = @(); tags = @('Adoption') } + [pscustomobject]@{ key = 'CopilotAdoption'; name = 'Copilot adoption'; collatable = $false; singleTenantOnly = $false + supportedOutputFormats = @('csv', 'pdf'); tags = @('Adoption') + requiredParameters = @([pscustomobject]@{ key = 'report-period'; description = 'days'; options = $null }) } + ) + $script:InforcerReportTypeCacheStamp = Get-Date + if ($Mode -eq 'beta') { + $script:InforcerSession = @{ + AuthMode = 'ApiKey'; ApiVersion = 'beta'; ApiHost = $ApiHost; BaseUrl = "$ApiHost/api"; Region = 'uk' + ConnectedAt = Get-Date; ApiKey = (ConvertTo-SecureString 'beta-test-key-value' -AsPlainText -Force) + } + } else { + $script:InforcerSession = @{ + AuthMode = 'ClientCredentials'; ApiVersion = 'v1'; ApiHost = $ApiHost; BaseUrl = $ApiHost; Region = 'uk' + ConnectedAt = Get-Date; ClientId = 'client-id-example' + ClientSecret = (ConvertTo-SecureString 'client-secret-example-value' -AsPlainText -Force) + AccessToken = (ConvertTo-SecureString 'access-token-example-value' -AsPlainText -Force) + TokenExpiresAt = [datetime]::UtcNow.AddMinutes(50) + } + } + } $Mode $ApiHost + } + + # Loopback HTTP server that records the headers of the last request. The port is claimed by + # starting the listener itself (retrying on a collision, no probe-then-release gap); the handler + # signals when it is ready, and one throwaway request warms Invoke-RestMethod up so the + # completer's 2 s budget never races JIT/first-use cost. + function global:Start-ReportsTestServer { + param([int]$Status = 200, [string]$Json = '{}') + $listener = $null + $port = 0 + for ($attempt = 0; $attempt -lt 25 -and -not $listener; $attempt++) { + $port = Get-Random -Minimum 20000 -Maximum 60000 + $candidate = [System.Net.HttpListener]::new() + $candidate.Prefixes.Add("http://127.0.0.1:$port/") + try { $candidate.Start(); $listener = $candidate } catch { $candidate.Close() } + } + if (-not $listener) { throw 'Could not start a loopback HttpListener on a free port.' } + $seen = [hashtable]::Synchronized(@{ Count = 0; Ready = $false }) + $shell = [powershell]::Create() + $null = $shell.AddScript({ + param($listener, $seen, $status, $json) + $seen.Ready = $true + while ($listener.IsListening) { + try { $ctx = $listener.GetContext() } catch { break } + $req = $ctx.Request + $seen.Auth = $req.Headers['Authorization'] + $seen.ApiKey = $req.Headers['Inf-Api-Key'] + $seen.ContentType = $req.ContentType + $seen.Method = $req.HttpMethod + $seen.Path = $req.RawUrl + $seen.Count = $seen.Count + 1 + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $ctx.Response.StatusCode = $status + $ctx.Response.ContentType = 'application/json' + $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $ctx.Response.Close() + } + }).AddArgument($listener).AddArgument($seen).AddArgument($Status).AddArgument($Json) + $null = $shell.BeginInvoke() + $waited = 0 + while (-not $seen.Ready -and $waited -lt 200) { Start-Sleep -Milliseconds 50; $waited++ } + $server = [pscustomobject]@{ ApiHost = "http://127.0.0.1:$port"; Seen = $seen; Listener = $listener; Shell = $shell } + try { $null = Invoke-RestMethod -Uri "$($server.ApiHost)/warmup" -TimeoutSec 30 -ErrorAction Stop } catch { $null } + $seen.Clear() + $seen.Count = 0 + $server + } + + function global:Stop-ReportsTestServer { + param($Server) + try { $Server.Listener.Stop(); $Server.Listener.Close() } catch { $null } + try { $Server.Shell.Stop(); $Server.Shell.Dispose() } catch { $null } + } + + $attr = (Get-Command Invoke-InforcerReport).Parameters['AssessmentId'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ArgumentCompleterAttribute] } + $script:AssessmentCompleter = $attr.ScriptBlock +} + +AfterAll { + Remove-Item function:global:Set-ReportsTestSession -ErrorAction SilentlyContinue + Remove-Item function:global:Start-ReportsTestServer -ErrorAction SilentlyContinue + Remove-Item function:global:Stop-ReportsTestServer -ErrorAction SilentlyContinue + & (Get-Module InforcerCommunity) { + $script:InforcerSession = $null + $script:InforcerTenantListCache = $null + $script:InforcerReportTypeCache = $null + $script:InforcerAssessmentCache = $null + } +} + +Describe 'TenantId aliases' { + It ' accepts -ClientTenantId and -MsTenantId' -ForEach @( + @{ Cmd = 'Invoke-InforcerReport' } + @{ Cmd = 'Invoke-InforcerAssessment' } + @{ Cmd = 'Save-InforcerReportOutput' } + ) { + $aliases = (Get-Command $Cmd).Parameters['TenantId'].Aliases + $aliases | Should -Contain 'ClientTenantId' + $aliases | Should -Contain 'MsTenantId' + } +} + +Describe 'Invoke-InforcerReport request body' { + BeforeEach { + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { [pscustomobject]@{ runId = '00000000-0000-0000-0000-0000000000a1' } } + } + + It 'v1 posts msTenantIds with GUIDs and the report entries, mapped as ReportRun' { + Set-ReportsTestSession -Mode v1 + $run = @(Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId $script:Tid1 -NoWait) + $run[0].runId | Should -Be $script:Run1 + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'POST' -and $Endpoint -eq '/reports/runs' -and $ObjectType -eq 'ReportRun' -and + (($Body | ConvertFrom-Json).msTenantIds -contains '00000000-0000-0000-0000-000000000001') -and + (($Body | ConvertFrom-Json).reports[0].type -eq 'ActiveUserCount') -and + (($Body | ConvertFrom-Json).reports[0].outputFormat -eq 'csv') -and + ($Body -notmatch 'includeTenants') + } + } + + It 'v1 passes collate and the auto-defaulted report-period parameter' { + Set-ReportsTestSession -Mode v1 + $null = Invoke-InforcerReport -ReportType CopilotAdoption -OutputFormat csv -TenantId $script:Tid1 -NoWait + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 1 -Exactly -ParameterFilter { + (($Body | ConvertFrom-Json).reports[0].parameters.'report-period' -eq '30') + } + $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId $script:Tid1 -Collate -NoWait + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 1 -Exactly -ParameterFilter { + (($Body | ConvertFrom-Json).reports[0].collate -eq $true) + } + } + + It 'beta keeps tenants.includeTenants with numeric Client Tenant IDs' { + Set-ReportsTestSession -Mode beta + $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 14436 -NoWait + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'POST' -and $Endpoint -eq '/reports/runs' -and + (($Body | ConvertFrom-Json).tenants.includeTenants -contains 14436) -and + ($Body -notmatch 'msTenantIds') + } + } + + It 'beta sorts and de-duplicates numeric tenant ids' { + Set-ReportsTestSession -Mode beta + $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 20, 10, 20 -NoWait + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 1 -Exactly -ParameterFilter { + (@(($Body | ConvertFrom-Json).tenants.includeTenants) -join ',') -eq '10,20' + } + } + + It 'v1 de-duplicates GUIDs case-insensitively without a tenant list lookup' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Get-InforcerTenantList { throw 'tenant list must not be fetched for GUIDs' } + $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId $script:Tid1, $script:Tid1.ToUpperInvariant() -NoWait + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 1 -Exactly -ParameterFilter { + @(($Body | ConvertFrom-Json).msTenantIds).Count -eq 1 + } + Should -Invoke -ModuleName InforcerCommunity Get-InforcerTenantList -Times 0 -Exactly + } + + It 'v1 resolves a tenant name to its Microsoft tenant ID' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Get-InforcerTenantList { + @([pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000002'; tenantFriendlyName = 'Contoso Example'; tenantDnsName = 'contoso.example.invalid' }) + } + $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 'Contoso Example' -NoWait + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 1 -Exactly -ParameterFilter { + (($Body | ConvertFrom-Json).msTenantIds -contains '00000000-0000-0000-0000-000000000002') + } + } + + It 'v1 refuses a numeric tenant id with the API-key explanation' { + Set-ReportsTestSession -Mode v1 + { Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 -NoWait -ErrorAction Stop } | + Should -Throw '*only works when you connect with an API key*' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 0 -Exactly + } + + It 'beta still rejects a non-positive tenant id' { + Set-ReportsTestSession -Mode beta + { Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 0 -NoWait -ErrorAction Stop } | + Should -Throw '*must be positive integers*' + } + + It '-WhatIf never posts' { + Set-ReportsTestSession -Mode v1 + $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId $script:Tid1 -WhatIf + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 0 -Exactly -ParameterFilter { $Method -eq 'POST' } + } + + It 'lookup failure hint names the API client permission on v1 and the API scope on beta' { + Mock -ModuleName InforcerCommunity Get-InforcerTenantList { Write-Error -Message 'denied' -ErrorId 'ApiRequestFailed_403' -Category ConnectionError } + Set-ReportsTestSession -Mode v1 + Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 'Contoso Example' -NoWait -ErrorVariable v1Err -ErrorAction SilentlyContinue + ($v1Err | Where-Object { $_.FullyQualifiedErrorId -like 'TenantLookupUnavailable*' } | Select-Object -First 1).Exception.Message | + Should -BeLike '*API client*Tenants permission*Microsoft tenant ID*' + Set-ReportsTestSession -Mode beta + Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 'Contoso Example' -NoWait -ErrorVariable betaErr -ErrorAction SilentlyContinue + ($betaErr | Where-Object { $_.FullyQualifiedErrorId -like 'TenantLookupUnavailable*' } | Select-Object -First 1).Exception.Message | + Should -BeLike '*Tenants.Read scope*numeric Client Tenant ID*' + } +} + +Describe 'Invoke-InforcerReport outputs' { + BeforeEach { + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { [pscustomobject]@{ runId = '00000000-0000-0000-0000-0000000000a1' } } + Mock -ModuleName InforcerCommunity Invoke-InforcerRawDownload { + $file = Join-Path $DestinationDirectory ($Endpoint -replace '.*/', '') + [System.IO.File]::WriteAllText($file, '{}') + [pscustomobject]@{ FilePath = $file; FileName = [System.IO.Path]::GetFileName($file); FileSize = 2; ContentType = 'application/json'; CorrelationId = 'cor-1' } + } + } + + It 'v1 keeps the Microsoft tenant ID per output, uses the Collated sentinel for a null tenant and downloads via the neutral endpoint' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Test-InforcerReportRunTerminal { + [pscustomobject]@{ IsTerminal = $true; Outputs = @( + [pscustomobject]@{ id = 'out-1'; reportType = 'ActiveUserCount'; tenantId = '00000000-0000-0000-0000-000000000001'; format = 'json'; sizeBytes = 2 } + [pscustomobject]@{ id = 'out-2'; reportType = 'ActiveUserCount'; tenantId = $null; format = 'json'; sizeBytes = 2 } + ) } + } + $r = @(Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat json -TenantId $script:Tid1, $script:Tid2 -OutputPath $TestDrive) + $r.Count | Should -Be 2 + $r[0].TenantId | Should -BeExactly $script:Tid1 + $r[1].TenantId | Should -BeExactly 'Collated' + $r[0].RunId | Should -Be $script:Run1 + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerRawDownload -Times 1 -Exactly -ParameterFilter { + $Endpoint -eq '/reports/runs/00000000-0000-0000-0000-0000000000a1/outputs/out-1' + } + } + + It 'v1 -Collate labels every output Collated' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Test-InforcerReportRunTerminal { + [pscustomobject]@{ IsTerminal = $true; Outputs = @( + [pscustomobject]@{ id = 'out-1'; reportType = 'ActiveUserCount'; tenantId = $null; format = 'json'; sizeBytes = 2 }) } + } + $r = @(Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat json -TenantId $script:Tid1, $script:Tid2 -Collate -OutputPath $TestDrive) + $r[0].TenantId | Should -BeExactly 'Collated' + } + + It 'beta keeps integer tenant ids and maps 0 / null / missing to Collated' { + Set-ReportsTestSession -Mode beta + Mock -ModuleName InforcerCommunity Test-InforcerReportRunTerminal { + [pscustomobject]@{ IsTerminal = $true; Outputs = @( + [pscustomobject]@{ id = 'out-1'; reportType = 'ActiveUserCount'; tenantId = 14436; format = 'json'; sizeBytes = 2 } + [pscustomobject]@{ id = 'out-2'; reportType = 'ActiveUserCount'; tenantId = 0; format = 'json'; sizeBytes = 2 } + [pscustomobject]@{ id = 'out-3'; reportType = 'ActiveUserCount'; format = 'json'; sizeBytes = 2 } + ) } + } + $r = @(Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat json -TenantId 14436 -OutputPath $TestDrive) + $r[0].TenantId | Should -BeOfType [int] + $r[0].TenantId | Should -Be 14436 + $r[1].TenantId | Should -BeExactly 'Collated' + $r[2].TenantId | Should -BeExactly 'Collated' + } +} + +Describe 'Test-InforcerReportRunTerminal' { + It 'v1 probes the v1 outputs URI with a bearer header and maps msTenantId to tenantId' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ StatusCode = 200; Headers = @{ 'x-correlation-id' = 'cor-1' } + Content = '{"outputs":[{"id":"out-1","reportType":"X","msTenantId":"00000000-0000-0000-0000-000000000001","format":"csv","sizeBytes":4},{"id":"out-2","reportType":"X","msTenantId":null,"format":"csv","sizeBytes":4}]}' } + } + $r = & $script:M { Test-InforcerReportRunTerminal -RunId ([guid]'00000000-0000-0000-0000-0000000000a1') } + $r.IsTerminal | Should -BeTrue + $r.Outputs.Count | Should -Be 2 + $r.Outputs[0].tenantId | Should -Be $script:Tid1 + $r.Outputs[1].tenantId | Should -BeNullOrEmpty + $r.Outputs[0].PSObject.Properties['msTenantId'] | Should -BeNullOrEmpty + Should -Invoke -ModuleName InforcerCommunity Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://api.example.invalid/v1/reports/runs/00000000-0000-0000-0000-0000000000a1/outputs' -and + $Headers['Authorization'] -eq 'Bearer access-token-example-value' -and -not $Headers.ContainsKey('Inf-Api-Key') + } + } + + It 'beta probes the beta URI with the API key and unwraps data.outputs' { + Set-ReportsTestSession -Mode beta + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ StatusCode = 200; Headers = @{} + Content = '{"data":{"outputs":[{"id":"out-1","reportType":"X","tenantId":14436,"format":"csv","sizeBytes":4}]},"success":true}' } + } + $r = & $script:M { Test-InforcerReportRunTerminal -RunId ([guid]'00000000-0000-0000-0000-0000000000a1') } + $r.Outputs[0].tenantId | Should -Be 14436 + Should -Invoke -ModuleName InforcerCommunity Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://api.example.invalid/api/beta/reports/runs/00000000-0000-0000-0000-0000000000a1/outputs' -and + $Headers['Inf-Api-Key'] -eq 'beta-test-key-value' -and -not $Headers.ContainsKey('Authorization') + } + } + + It 'v1 treats 404 as not terminal yet' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ StatusCode = 404; Headers = @{}; Content = '{"title":"Not Found","status":404}' } + } + $r = & $script:M { Test-InforcerReportRunTerminal -RunId ([guid]'00000000-0000-0000-0000-0000000000a1') } + $r.IsTerminal | Should -BeFalse + $r.Outputs | Should -BeNullOrEmpty + } + + It 'fetches the auth header on every probe so a long wait picks up a renewed v1 token' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Get-InforcerAuthHeader { @{ Authorization = 'Bearer renewed-token-example' } } + Mock -ModuleName InforcerCommunity Invoke-WebRequest { [pscustomobject]@{ StatusCode = 404; Headers = @{}; Content = '{}' } } + $null = & $script:M { $id = [guid]'00000000-0000-0000-0000-0000000000a1'; Test-InforcerReportRunTerminal -RunId $id; Test-InforcerReportRunTerminal -RunId $id } + Should -Invoke -ModuleName InforcerCommunity Get-InforcerAuthHeader -Times 2 -Exactly + Should -Invoke -ModuleName InforcerCommunity Invoke-WebRequest -Times 2 -Exactly -ParameterFilter { $Headers['Authorization'] -eq 'Bearer renewed-token-example' } + } + + It 'v1 renews the token once after a 401 and retries' { + Set-ReportsTestSession -Mode v1 + & $script:M { $script:ProbeCalls = 0 } + Mock -ModuleName InforcerCommunity Get-InforcerAuthHeader { @{ Authorization = 'Bearer token-example' } } + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + $script:ProbeCalls++ + if ($script:ProbeCalls -eq 1) { [pscustomobject]@{ StatusCode = 401; Headers = @{}; Content = '{"title":"Unauthorized","status":401}' } } + else { [pscustomobject]@{ StatusCode = 200; Headers = @{}; Content = '{"outputs":[]}' } } + } + $r = & $script:M { Test-InforcerReportRunTerminal -RunId ([guid]'00000000-0000-0000-0000-0000000000a1') } + $r.IsTerminal | Should -BeTrue + Should -Invoke -ModuleName InforcerCommunity Get-InforcerAuthHeader -Times 1 -Exactly -ParameterFilter { $ForceRefresh } + Should -Invoke -ModuleName InforcerCommunity Invoke-WebRequest -Times 2 -Exactly + } + + It 'surfaces the problem+json title and detail for other errors' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ StatusCode = 403; Headers = @{}; Content = '{"title":"Forbidden","detail":"Missing reports permission","status":403}' } + } + $err = & $script:M { + Test-InforcerReportRunTerminal -RunId ([guid]'00000000-0000-0000-0000-0000000000a1') -ErrorVariable e -ErrorAction SilentlyContinue + $e + } + "$err" | Should -BeLike '*Forbidden: Missing reports permission*' + } +} + +Describe 'Invoke-InforcerRawDownload' { + It 'v1 downloads from the v1 URI with a bearer header' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ StatusCode = 200; Headers = @{ 'Content-Disposition' = 'attachment; filename="Report.csv"'; 'Content-Type' = 'text/csv' } + Content = [System.Text.Encoding]::UTF8.GetBytes('a,b') } + } + $r = & $script:M { Invoke-InforcerRawDownload -Endpoint '/reports/runs/r1/outputs/o1' } + $r.FileName | Should -Be 'Report.csv' + $r.Bytes.Length | Should -Be 3 + Should -Invoke -ModuleName InforcerCommunity Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://api.example.invalid/v1/reports/runs/r1/outputs/o1' -and $Headers['Accept'] -eq '*/*' -and + $Headers['Authorization'] -eq 'Bearer access-token-example-value' -and -not $Headers.ContainsKey('Inf-Api-Key') + } + } + + It 'beta downloads from the beta URI with the API key' { + Set-ReportsTestSession -Mode beta + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ StatusCode = 200; Headers = @{}; Content = [System.Text.Encoding]::UTF8.GetBytes('a,b') } + } + $null = & $script:M { Invoke-InforcerRawDownload -Endpoint '/reports/runs/r1/outputs/o1' } + Should -Invoke -ModuleName InforcerCommunity Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://api.example.invalid/api/beta/reports/runs/r1/outputs/o1' -and + $Headers['Inf-Api-Key'] -eq 'beta-test-key-value' -and -not $Headers.ContainsKey('Authorization') + } + } + + It 'v1 reports the problem+json title and detail on a 404' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ StatusCode = 404; Headers = @{} + Content = [System.Text.Encoding]::UTF8.GetBytes('{"title":"Not Found","detail":"Output not available","status":404}') } + } + $err = & $script:M { + Invoke-InforcerRawDownload -Endpoint '/reports/runs/r1/outputs/o1' -ErrorVariable e -ErrorAction SilentlyContinue + $e + } + "$err" | Should -BeLike '*HTTP 404*Not Found: Output not available*' + } + + It 'v1 renews the token and retries once after a 401' { + Set-ReportsTestSession -Mode v1 + & $script:M { $script:DownloadCalls = 0 } + Mock -ModuleName InforcerCommunity Get-InforcerAuthHeader { @{ Authorization = 'Bearer token-example' } } + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + $script:DownloadCalls++ + if ($script:DownloadCalls -eq 1) { [pscustomobject]@{ StatusCode = 401; Headers = @{}; Content = [System.Text.Encoding]::UTF8.GetBytes('{}') } } + else { [pscustomobject]@{ StatusCode = 200; Headers = @{}; Content = [System.Text.Encoding]::UTF8.GetBytes('ok') } } + } + $r = & $script:M { Invoke-InforcerRawDownload -Endpoint '/reports/runs/r1/outputs/o1' } + $r.Bytes.Length | Should -Be 2 + Should -Invoke -ModuleName InforcerCommunity Get-InforcerAuthHeader -Times 1 -Exactly -ParameterFilter { $ForceRefresh } + } +} + +Describe 'Invoke-InforcerAssessmentRun runspace' { + BeforeEach { $script:Server = $null } + AfterEach { if ($script:Server) { Stop-ReportsTestServer $script:Server } } + + It 'v1 hands the runspace a bearer header and the v1 URI, sends no Content-Type, and maps assessmentId' { + $script:Server = Start-ReportsTestServer -Json '{"assessmentId":"a1","name":"Example Assessment","results":[]}' + Set-ReportsTestSession -Mode v1 -ApiHost $script:Server.ApiHost + $r = & $script:M { + param($t) + Invoke-InforcerAssessmentRun -TenantId $t -ResolvedAssessmentId 'a1' -TenantDisplayName 'Contoso Example' -AssessmentDisplayName 'Example Assessment' 6>$null + } $script:Tid1 + $script:Server.Seen.Method | Should -Be 'POST' + $script:Server.Seen.Path | Should -Be "/v1/tenants/$($script:Tid1)/assessments/a1/runs" + $script:Server.Seen.Auth | Should -BeExactly 'Bearer access-token-example-value' + $script:Server.Seen.ApiKey | Should -BeNullOrEmpty + $script:Server.Seen.ContentType | Should -BeNullOrEmpty + $r.assessment | Should -Be 'a1' + $r.PSObject.Properties['assessmentId'] | Should -BeNullOrEmpty + } + + It 'beta hands the runspace the API key and the beta URI and returns .data' { + $script:Server = Start-ReportsTestServer -Json '{"data":{"assessment":"a1","results":[]},"success":true}' + Set-ReportsTestSession -Mode beta -ApiHost $script:Server.ApiHost + $r = & $script:M { + Invoke-InforcerAssessmentRun -TenantId 14436 -ResolvedAssessmentId 'a1' -TenantDisplayName 'Contoso Example' -AssessmentDisplayName 'Example Assessment' 6>$null + } + $script:Server.Seen.Path | Should -Be '/api/beta/tenants/14436/assessments/a1/runs' + $script:Server.Seen.ApiKey | Should -BeExactly 'beta-test-key-value' + $script:Server.Seen.Auth | Should -BeNullOrEmpty + $script:Server.Seen.ContentType | Should -BeNullOrEmpty + $r.assessment | Should -Be 'a1' + } + + It 'v1 shows the problem+json title and detail when the run is rejected' { + $script:Server = Start-ReportsTestServer -Status 409 -Json '{"title":"Conflict","detail":"Assessment already running","status":409}' + Set-ReportsTestSession -Mode v1 -ApiHost $script:Server.ApiHost + $err = & $script:M { + param($t) + $null = Invoke-InforcerAssessmentRun -TenantId $t -ResolvedAssessmentId 'a1' -TenantDisplayName 'Contoso Example' -AssessmentDisplayName 'Example Assessment' -ErrorVariable e -ErrorAction SilentlyContinue 6>$null + $e + } $script:Tid1 + ($err | ForEach-Object { "$_" }) -join ' ' | Should -BeLike '*Conflict: Assessment already running*' + } +} + +Describe 'AssessmentId completer' { + BeforeEach { $script:Server = $null } + AfterEach { if ($script:Server) { Stop-ReportsTestServer $script:Server } } + + It 'v1 fetches /v1/assessments with a bearer header built inside the module and caches the list' { + $script:Server = Start-ReportsTestServer -Json '{"assessments":[{"id":"a1","name":"Copilot Readiness Example"}]}' + Set-ReportsTestSession -Mode v1 -ApiHost $script:Server.ApiHost + $res = @(& $script:AssessmentCompleter 'Invoke-InforcerReport' 'AssessmentId' '' $null @{ ReportType = 'Assessment' }) + $res.Count | Should -Be 1 + $res[0].CompletionText | Should -Be 'a1' + $res[0].ListItemText | Should -Be 'Copilot Readiness Example' + $script:Server.Seen.Path | Should -Be '/v1/assessments' + $script:Server.Seen.Auth | Should -BeExactly 'Bearer access-token-example-value' + (& $script:M { @($script:InforcerAssessmentCache).Count }) | Should -Be 1 + } + + It 'beta fetches the beta endpoint with the API key and reads data' { + $script:Server = Start-ReportsTestServer -Json '{"data":[{"id":"b1","name":"Beta Example"}],"success":true}' + Set-ReportsTestSession -Mode beta -ApiHost $script:Server.ApiHost + $res = @(& $script:AssessmentCompleter 'Invoke-InforcerReport' 'AssessmentId' 'b' $null @{ ReportType = 'Assessment' }) + $res[0].CompletionText | Should -Be 'b1' + $script:Server.Seen.Path | Should -Be '/api/beta/assessments' + $script:Server.Seen.ApiKey | Should -BeExactly 'beta-test-key-value' + $script:Server.Seen.Auth | Should -BeNullOrEmpty + } + + It 'v1 403 caches a denial labelled with the API client permission' { + $script:Server = Start-ReportsTestServer -Status 403 -Json '{"title":"Forbidden","status":403}' + Set-ReportsTestSession -Mode v1 -ApiHost $script:Server.ApiHost + $res = @(& $script:AssessmentCompleter 'Invoke-InforcerReport' 'AssessmentId' '' $null @{ ReportType = 'Assessment' }) + $res[0].ListItemText | Should -Be '' + (& $script:M { [bool]$script:InforcerAssessmentCacheDeniedAt }) | Should -BeTrue + } + + It 'v1 serves the cache without any request' { + Set-ReportsTestSession -Mode v1 + & $script:M { $script:InforcerAssessmentCache = @([pscustomobject]@{ id = 'a9'; name = 'Cached Example' }) } + $res = @(& $script:AssessmentCompleter 'Invoke-InforcerReport' 'AssessmentId' 'cach' $null @{ ReportType = 'Assessment' }) + $res[0].CompletionText | Should -Be 'a9' + } + + It 'offers nothing when another report type is bound, and assessments while -ReportType is still unbound' { + Set-ReportsTestSession -Mode v1 + & $script:M { $script:InforcerAssessmentCache = @([pscustomobject]@{ id = 'a9'; name = 'Cached Example' }) } + @(& $script:AssessmentCompleter 'Invoke-InforcerReport' 'AssessmentId' '' $null @{ ReportType = 'ActiveUserCount' }).Count | Should -Be 0 + @(& $script:AssessmentCompleter 'Invoke-InforcerReport' 'AssessmentId' '' $null @{})[0].CompletionText | Should -Be 'a9' + } + + It 'offers a connect hint when there is no session' { + & $script:M { $script:InforcerSession = $null } + $res = @(& $script:AssessmentCompleter 'Invoke-InforcerReport' 'AssessmentId' '' $null @{ ReportType = 'Assessment' }) + $res[0].ListItemText | Should -Be '' + } +} + +Describe 'Assessment and report listings on v1' { + It 'Get-InforcerAssessment reads GET /v1/assessments' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ assessments = @([pscustomobject]@{ id = 'a1'; name = 'Copilot Readiness Example'; assessmentType = 'platform' }) } + } + $r = @(Get-InforcerAssessment) + $r.Count | Should -Be 1 + $r[0].id | Should -Be 'a1' + $r[0].PSObject.TypeNames[0] | Should -Be 'InforcerCommunity.Assessment' + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://api.example.invalid/v1/assessments' } + } + + It 'Get-InforcerAssessment -OutputType JsonObject stays a JSON array for one assessment' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ assessments = @([pscustomobject]@{ id = 'a1'; name = 'Example' }) } } + (Get-InforcerAssessment -OutputType JsonObject).TrimStart() | Should -Match '^\[' + } + + It 'Resolve-InforcerAssessmentId resolves a name through the v1 list' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ assessments = @([pscustomobject]@{ id = 'a1'; name = 'Copilot Readiness Example' }) } } + (& $script:M { Resolve-InforcerAssessmentId -AssessmentId 'copilot readiness example' }) | Should -Be 'a1' + } + + It 'Get-InforcerReportType reads GET /v1/reports/types, keeps singleTenantOnly and primes the cache' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ reportTypes = @([pscustomobject]@{ key = 'ActiveUserCount'; name = 'Active users'; collatable = $true; singleTenantOnly = $false + supportedOutputFormats = @('csv', 'json'); tags = @('Adoption'); requiredParameters = @() }) } + } + $r = @(Get-InforcerReportType -Force) + $r.Count | Should -Be 1 + $r[0].key | Should -Be 'ActiveUserCount' + $r[0].singleTenantOnly | Should -BeFalse + @(Get-InforcerReportType -OutputFormat csv).Count | Should -Be 1 + @(Get-InforcerReportType -OutputFormat pdf).Count | Should -Be 0 + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://api.example.invalid/v1/reports/types' } + } + + It 'Get-InforcerReportRun maps reportRunId to runId and filters by -RunId' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-RestMethod { + [pscustomobject]@{ reportRuns = @( + [pscustomobject]@{ reportRunId = '00000000-0000-0000-0000-0000000000a1'; status = 'completed' } + [pscustomobject]@{ reportRunId = '00000000-0000-0000-0000-0000000000a2'; status = 'running' }) } + } + @(Get-InforcerReportRun).Count | Should -Be 2 + $one = @(Get-InforcerReportRun -RunId $script:Run1) + $one.Count | Should -Be 1 + $one[0].runId | Should -Be $script:Run1 + $one[0].Id | Should -Be $script:Run1 + $one[0].PSObject.Properties['reportRunId'] | Should -BeNullOrEmpty + Should -Invoke -ModuleName InforcerCommunity Invoke-RestMethod -ParameterFilter { $Uri -eq 'https://api.example.invalid/v1/reports/runs' } + } + + It 'Get-InforcerReportRun -IncludeOutputs embeds the mapped outputs' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-RestMethod { [pscustomobject]@{ reportRuns = @([pscustomobject]@{ reportRunId = '00000000-0000-0000-0000-0000000000a1'; status = 'completed' }) } } + Mock -ModuleName InforcerCommunity Test-InforcerReportRunTerminal { + [pscustomobject]@{ IsTerminal = $true; Outputs = @([pscustomobject]@{ id = 'out-1'; tenantId = $null; format = 'csv' }) } + } + $r = @(Get-InforcerReportRun -IncludeOutputs) + $r[0].outputs.Count | Should -Be 1 + $r[0].outputs[0].id | Should -Be 'out-1' + } +} + +Describe 'Save-InforcerReportOutput' { + It 'downloads through the neutral endpoint and carries the tenant given via -MsTenantId' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Invoke-InforcerRawDownload { + $file = Join-Path $DestinationDirectory 'x.csv' + [System.IO.File]::WriteAllText($file, 'a') + [pscustomobject]@{ FilePath = $file; FileName = 'x.csv'; FileSize = 1; ContentType = 'text/csv'; CorrelationId = 'cor-1' } + } + $r = Save-InforcerReportOutput -RunId $script:Run1 -OutputId 'out-1' -MsTenantId $script:Tid1 -OutputPath $TestDrive -Confirm:$false + $r.TenantId | Should -Be $script:Tid1 + $r.RunId | Should -Be $script:Run1 + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerRawDownload -Times 1 -Exactly -ParameterFilter { + $Endpoint -eq '/reports/runs/00000000-0000-0000-0000-0000000000a1/outputs/out-1' + } + } +} + +Describe 'Invoke-InforcerAssessment tenant handling' { + BeforeEach { + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { @([pscustomobject]@{ id = 'a1'; name = 'Example Assessment' }) } + Mock -ModuleName InforcerCommunity Invoke-InforcerAssessmentRun { [pscustomobject]@{ assessment = 'a1'; results = @() } } + } + + It 'v1 -MultiTenant finds the tenants through Get-InforcerTenantList and runs against their Microsoft tenant IDs' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Get-InforcerTenantList { + @([pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000001'; tenantFriendlyName = 'Contoso Example'; tenantDnsName = 'contoso.example.invalid' } + [pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000002'; tenantFriendlyName = 'Fabrikam Example'; tenantDnsName = 'fabrikam.example.invalid' }) + } + $json = Invoke-InforcerAssessment -AssessmentId 'a1' -MultiTenant -OutputType JsonObject 6>$null | ConvertFrom-Json + @($json).Count | Should -Be 2 + $json[0].TenantId | Should -Be $script:Tid1 + $json[0].TenantName | Should -Be 'Contoso Example' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerAssessmentRun -Times 1 -Exactly -ParameterFilter { $TenantId -eq '00000000-0000-0000-0000-000000000001' } + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerAssessmentRun -Times 1 -Exactly -ParameterFilter { $TenantId -eq '00000000-0000-0000-0000-000000000002' } + } + + It 'v1 resolves tenant names for a subset run' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Get-InforcerTenantList { + @([pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000001'; tenantFriendlyName = 'Contoso Example'; tenantDnsName = 'contoso.example.invalid' } + [pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000002'; tenantFriendlyName = 'Fabrikam Example'; tenantDnsName = 'fabrikam.example.invalid' }) + } + $null = Invoke-InforcerAssessment -TenantId 'Fabrikam Example', 'Contoso Example' -AssessmentId 'a1' -OutputType JsonObject 6>$null + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerAssessmentRun -Times 1 -Exactly -ParameterFilter { $TenantId -eq '00000000-0000-0000-0000-000000000002' } + } + + It 'v1 accepts a GUID for a single tenant and refuses a numeric id' { + Set-ReportsTestSession -Mode v1 + Mock -ModuleName InforcerCommunity Get-InforcerTenantList { + @([pscustomobject]@{ msTenantId = '00000000-0000-0000-0000-000000000001'; tenantFriendlyName = 'Contoso Example'; tenantDnsName = 'contoso.example.invalid' }) + } + $null = Invoke-InforcerAssessment -TenantId $script:Tid1 -AssessmentId 'a1' -OutputType JsonObject 6>$null + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerAssessmentRun -Times 1 -Exactly -ParameterFilter { $TenantId -eq '00000000-0000-0000-0000-000000000001' } + { Invoke-InforcerAssessment -TenantId 482 -AssessmentId 'a1' -ErrorAction Stop 6>$null } | Should -Throw '*only works when you connect with an API key*' + } + + It 'beta -MultiTenant still uses numeric Client Tenant IDs' { + Set-ReportsTestSession -Mode beta + Mock -ModuleName InforcerCommunity Get-InforcerTenantList { + @([pscustomobject]@{ clientTenantId = 1; tenantFriendlyName = 'Contoso Example'; tenantDnsName = 'contoso.example.invalid' } + [pscustomobject]@{ clientTenantId = 2; tenantFriendlyName = 'Fabrikam Example'; tenantDnsName = 'fabrikam.example.invalid' }) + } + $json = Invoke-InforcerAssessment -AssessmentId 'a1' -MultiTenant -OutputType JsonObject 6>$null | ConvertFrom-Json + $json[1].TenantId | Should -Be 2 + $json[1].TenantName | Should -Be 'Fabrikam Example' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerAssessmentRun -Times 1 -Exactly -ParameterFilter { $TenantId -eq '1' } + } +} diff --git a/module/Private/Invoke-InforcerAssessmentRun.ps1 b/module/Private/Invoke-InforcerAssessmentRun.ps1 index 392fc4a..9d19896 100644 --- a/module/Private/Invoke-InforcerAssessmentRun.ps1 +++ b/module/Private/Invoke-InforcerAssessmentRun.ps1 @@ -3,12 +3,18 @@ function Invoke-InforcerAssessmentRun { .SYNOPSIS Runs a single assessment against one tenant (internal helper). .DESCRIPTION - Executes POST /beta/tenants/{id}/assessments/{id}/runs asynchronously - with progress updates. Returns the raw API response data or $null on error. + Executes POST /tenants/{id}/assessments/{id}/runs in a separate runspace so the caller can + show progress. The URI and auth header are built here with Get-InforcerBaseUrl and + Get-InforcerAuthHeader (fresh per run, so a long multi-tenant loop never reuses an expired + v1 token) and handed to the runspace as parameters; the runspace never sees the session. + Returns the raw API response data (beta: .data; v1: the run object, assessmentId mapped to + assessment) or $null on error. + .PARAMETER TenantId + beta: numeric Client Tenant ID. v1: Microsoft tenant ID (GUID). #> [CmdletBinding()] param( - [Parameter(Mandatory)] [int]$ClientTenantId, + [Parameter(Mandatory)] [Alias('ClientTenantId')] [string]$TenantId, [Parameter(Mandatory)] [string]$ResolvedAssessmentId, [Parameter(Mandatory)] [string]$TenantDisplayName, [Parameter(Mandatory)] [string]$AssessmentDisplayName @@ -27,10 +33,26 @@ function Invoke-InforcerAssessmentRun { Write-Host "Running '$AssessmentDisplayName' against $TenantDisplayName..." - $endpoint = "/beta/tenants/$ClientTenantId/assessments/$ResolvedAssessmentId/runs" - $uri = $script:InforcerSession.BaseUrl + $endpoint - $apiKey = ConvertFrom-InforcerSecureString -SecureString $script:InforcerSession.ApiKey - $headers = @{ 'Inf-Api-Key' = $apiKey } + $isV1 = $script:InforcerSession.ApiVersion -eq 'v1' + $uri = Get-InforcerBaseUrl -Endpoint "/tenants/$TenantId/assessments/$ResolvedAssessmentId/runs" + $headers = Get-InforcerAuthHeader + if (-not $headers) { return $null } + + $formatApiError = { + param([string]$errMsg) + if ($errMsg -match '([^<]+)') { return "API error: $($Matches[1].Trim())" } + if ($isV1 -and $errMsg.TrimStart().StartsWith('{')) { + try { + $problem = $errMsg | ConvertFrom-Json -ErrorAction Stop + $title = $problem.PSObject.Properties['title'].Value -as [string] + $detail = $problem.PSObject.Properties['detail'].Value -as [string] + if ($title -and $detail -and $detail -ne $title) { return "${title}: $detail" } + if ($title) { return $title } + } catch { Write-Verbose 'Error body was not JSON; using raw text.' } + } + if ($errMsg.Length -gt 500) { $errMsg = $errMsg.Substring(0, 200) + '...' } + $errMsg + } # This endpoint takes no request body and rejects ANY Content-Type with # 400 ValidationFailure ("Unspecified content type is not allowed"). Omitting the @@ -63,17 +85,13 @@ function Invoke-InforcerAssessmentRun { if ($ps.Streams.Error.Count -gt 0) { $hasError = $true foreach ($e in $ps.Streams.Error) { - $errMsg = $e.ToString() - if ($errMsg -match '([^<]+)') { $errMsg = "API error: $($Matches[1].Trim())" } - elseif ($errMsg.Length -gt 500) { $errMsg = $errMsg.Substring(0, 200) + '...' } + $errMsg = Protect-InforcerApiKeyInText -Text (& $formatApiError $e.ToString()) Write-Error -Message $errMsg -ErrorId 'AssessmentRunFailed' -Category InvalidResult } } } catch { $hasError = $true - $errMsg = $_.Exception.Message - if ($errMsg -match '([^<]+)') { $errMsg = "API error: $($Matches[1].Trim())" } - elseif ($errMsg.Length -gt 500) { $errMsg = $errMsg.Substring(0, 200) + '...' } + $errMsg = Protect-InforcerApiKeyInText -Text (& $formatApiError $_.Exception.Message) Write-Error -Message "Assessment run failed: $errMsg" -ErrorId 'AssessmentRunFailed' -Category InvalidResult } } finally { @@ -107,5 +125,7 @@ function Invoke-InforcerAssessmentRun { return $null } $dataProp = $responseObj.PSObject.Properties['data'] - if ($dataProp) { return $dataProp.Value } else { return $responseObj } + if ($dataProp) { return $dataProp.Value } + if ($isV1) { return ConvertFrom-InforcerV1Response -InputObject $responseObj -ObjectType AssessmentRun } + return $responseObj } diff --git a/module/Private/Invoke-InforcerRawDownload.ps1 b/module/Private/Invoke-InforcerRawDownload.ps1 index 1940f1d..ef950c2 100644 --- a/module/Private/Invoke-InforcerRawDownload.ps1 +++ b/module/Private/Invoke-InforcerRawDownload.ps1 @@ -7,13 +7,16 @@ function Invoke-InforcerRawDownload { rather than UTF-8-decoded text. Returns the bytes alongside the server-suggested filename (parsed from Content-Disposition) and the x-correlation-id for support. - Used by Save-InforcerReportOutput to fetch /beta/reports/runs/{id}/outputs/{id} — - which can be CSV, JSON, HTML, PDF, or other binary content. + Used by Save-InforcerReportOutput to fetch /reports/runs/{id}/outputs/{id} — which can be + CSV, JSON, HTML, PDF, or other binary content. The URI and auth header come from + Get-InforcerBaseUrl / Get-InforcerAuthHeader, so it works for API-key (beta) and + client-credentials (v1) sessions; a v1 401 renews the access token and retries once. Error envelope handling mirrors Invoke-InforcerApiRequest: app-layer, APIM, and RFC 9110 ProblemDetails shapes are all parsed for a usable error message. .PARAMETER Endpoint - API path (e.g. /beta/reports/runs/.../outputs/...). Leading slash optional. + Version-neutral API path (e.g. /reports/runs/.../outputs/...). Leading slash optional; + a leading /beta is ignored. .PARAMETER DefaultFileName Suggested filename when the server doesn't return a usable Content-Disposition. Defaults to 'output'. @@ -50,24 +53,7 @@ function Invoke-InforcerRawDownload { return } - $endpoint = $Endpoint.Trim() - if (-not $endpoint.StartsWith('/')) { - $endpoint = '/' + $endpoint - } - - $uri = $script:InforcerSession.BaseUrl + $endpoint - - $apiKey = ConvertFrom-InforcerSecureString -SecureString $script:InforcerSession.ApiKey - if ([string]::IsNullOrWhiteSpace($apiKey)) { - Write-Error -Message 'API key is empty or invalid. Please reconnect.' ` - -ErrorId 'EmptyApiKey' -Category AuthenticationError - return - } - - $headers = @{ - 'Inf-Api-Key' = $apiKey - 'Accept' = '*/*' - } + $uri = Get-InforcerBaseUrl -Endpoint $Endpoint Write-Verbose "Downloading: GET $uri" @@ -84,23 +70,41 @@ function Invoke-InforcerRawDownload { $tempStreamPath = Join-Path -Path $DestinationDirectory -ChildPath ('inforcer-download-' + [guid]::NewGuid().ToString('N') + '.partial') } - try { - # -SkipHttpErrorCheck lets us inspect status code and body without exception-based flow. - if ($tempStreamPath) { - $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck ` - -UseBasicParsing -OutFile $tempStreamPath -PassThru -ErrorAction Stop - } else { - $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck ` - -UseBasicParsing -ErrorAction Stop + $isV1 = $script:InforcerSession.ApiVersion -eq 'v1' + $forceRefresh = $false + while ($true) { + $headers = Get-InforcerAuthHeader -ForceRefresh:$forceRefresh + if (-not $headers) { return } + $headers['Accept'] = '*/*' + + try { + # -SkipHttpErrorCheck lets us inspect status code and body without exception-based flow. + if ($tempStreamPath) { + $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck ` + -UseBasicParsing -OutFile $tempStreamPath -PassThru -ErrorAction Stop + } else { + $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck ` + -UseBasicParsing -ErrorAction Stop + } + } catch { + if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) { + Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue + } + $msg = Protect-InforcerApiKeyInText -Text $_.Exception.Message + Write-Error -Message "Download request failed: $msg" ` + -ErrorId 'DownloadFailed' -Category ConnectionError + return } - } catch { - if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) { - Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue + + if ($isV1 -and -not $forceRefresh -and [int]$response.StatusCode -eq 401) { + Write-Verbose 'HTTP 401: renewing the access token and retrying once.' + if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) { + Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue + } + $forceRefresh = $true + continue } - $msg = Protect-InforcerApiKeyInText -Text $_.Exception.Message -ApiKey $apiKey - Write-Error -Message "Download request failed: $msg" ` - -ErrorId 'DownloadFailed' -Category ConnectionError - return + break } $statusCode = [int]$response.StatusCode @@ -134,6 +138,8 @@ function Invoke-InforcerRawDownload { $apiMessage = $json.PSObject.Properties['message'].Value -as [string] } elseif ($null -ne $json.PSObject.Properties['title']) { $apiMessage = $json.PSObject.Properties['title'].Value -as [string] + $problemDetail = $json.PSObject.Properties['detail'].Value -as [string] + if ($problemDetail -and $problemDetail -ne $apiMessage) { $apiMessage = "${apiMessage}: $problemDetail" } if ($null -ne $json.PSObject.Properties['traceId']) { $traceId = $json.PSObject.Properties['traceId'].Value -as [string] } @@ -145,7 +151,7 @@ function Invoke-InforcerRawDownload { } } if ([string]::IsNullOrWhiteSpace($detail)) { $detail = "HTTP $statusCode" } - $detail = Protect-InforcerApiKeyInText -Text $detail -ApiKey $apiKey + $detail = Protect-InforcerApiKeyInText -Text $detail $msg = "Inforcer API download failed (HTTP $statusCode): $detail" if ($correlationId) { $msg = "$msg [correlation-id: $correlationId]" } Write-Error -Message $msg -ErrorId "DownloadFailed_$statusCode" -Category ConnectionError diff --git a/module/Private/Resolve-InforcerAssessmentId.ps1 b/module/Private/Resolve-InforcerAssessmentId.ps1 index f0cd931..85de293 100644 --- a/module/Private/Resolve-InforcerAssessmentId.ps1 +++ b/module/Private/Resolve-InforcerAssessmentId.ps1 @@ -9,7 +9,7 @@ function Resolve-InforcerAssessmentId { .PARAMETER AssessmentId Assessment ID string or friendly assessment name. .PARAMETER AssessmentData - Optional. Pre-fetched array of assessment objects (e.g. from GET /beta/assessments). When provided + Optional. Pre-fetched array of assessment objects (e.g. from GET /assessments). When provided and AssessmentId is a name, resolution is done from this data instead of calling the API. .OUTPUTS System.String - The resolved assessment ID. @@ -30,7 +30,7 @@ function Resolve-InforcerAssessmentId { Write-Verbose "Resolving assessment: '$assessmentIdString'..." $assessmentsToUse = $AssessmentData if ($null -eq $assessmentsToUse -or $assessmentsToUse.Count -eq 0) { - $assessmentsToUse = @(Invoke-InforcerApiRequest -Endpoint '/beta/assessments' -Method GET -OutputType PowerShellObject) + $assessmentsToUse = @(Invoke-InforcerApiRequest -Endpoint '/assessments' -Method GET -OutputType PowerShellObject -ObjectType Assessment) } $assessments = if ($assessmentsToUse -is [array]) { $assessmentsToUse } else { @($assessmentsToUse) } diff --git a/module/Private/Resolve-InforcerReportTypeSchema.ps1 b/module/Private/Resolve-InforcerReportTypeSchema.ps1 index ac181d2..22c2819 100644 --- a/module/Private/Resolve-InforcerReportTypeSchema.ps1 +++ b/module/Private/Resolve-InforcerReportTypeSchema.ps1 @@ -5,7 +5,7 @@ function Resolve-InforcerReportTypeSchema { and returns the normalized POST /reports/runs entry (Private helper). .DESCRIPTION Uses the module-scoped $script:InforcerReportTypeCache. If the cache is empty, lazy-fetches - the catalog from GET /beta/reports/types via Invoke-InforcerApiRequest. + the catalog from GET /reports/types via Invoke-InforcerApiRequest. Validates client-side (so the user gets a clear error before the API does): * ReportType is in the catalog (case-insensitive) @@ -83,17 +83,12 @@ function Resolve-InforcerReportTypeSchema { } if ($Force -or $null -eq $script:InforcerReportTypeCache -or $cacheIsStale) { try { - $catalog = Invoke-InforcerApiRequest -Endpoint '/beta/reports/types' -Method GET -ErrorAction Stop + $catalog = Invoke-InforcerApiRequest -Endpoint '/reports/types' -Method GET -ObjectType ReportType -ErrorAction Stop } catch { $catalog = $null - # Defensive re-scrub: even though Invoke-InforcerApiRequest already redacts API - # keys, surfacing $_.Exception.Message in our own Write-Warning is safer with one - # more pass through Protect-InforcerApiKeyInText. - $rawMsg = $_.Exception.Message - $catalogFetchError = if ($script:InforcerSession -and $script:InforcerSession.ApiKey) { - $maskedKey = ConvertFrom-InforcerSecureString -SecureString $script:InforcerSession.ApiKey - Protect-InforcerApiKeyInText -Text $rawMsg -ApiKey $maskedKey - } else { $rawMsg } + # Defensive re-scrub of credentials (API key, client secret, access token) before the + # message reaches our own Write-Warning. + $catalogFetchError = Protect-InforcerApiKeyInText -Text $_.Exception.Message } if ($catalog) { $script:InforcerReportTypeCache = @($catalog) @@ -123,7 +118,7 @@ function Resolve-InforcerReportTypeSchema { # surfacing "Unknown report type" — auto-recovery beats forcing the user to disconnect. Write-Verbose "Type '$ReportType' not found in cached catalog; refetching once before failing." try { - $refreshed = Invoke-InforcerApiRequest -Endpoint '/beta/reports/types' -Method GET -ErrorAction Stop + $refreshed = Invoke-InforcerApiRequest -Endpoint '/reports/types' -Method GET -ObjectType ReportType -ErrorAction Stop } catch { $refreshed = $null } diff --git a/module/Private/Test-InforcerReportRunTerminal.ps1 b/module/Private/Test-InforcerReportRunTerminal.ps1 index 6eb2a19..59d1d4d 100644 --- a/module/Private/Test-InforcerReportRunTerminal.ps1 +++ b/module/Private/Test-InforcerReportRunTerminal.ps1 @@ -1,21 +1,26 @@ function Test-InforcerReportRunTerminal { <# .SYNOPSIS - Probes GET /beta/reports/runs/{id}/outputs once to determine if a run is terminal (Private helper). + Probes GET /reports/runs/{id}/outputs once to determine if a run is terminal (Private helper). .DESCRIPTION - Per API contract: + Per API contract (both API versions): * 200 → run is terminal; outputs are returned (downloadable) * 404 → run is not terminal yet (or doesn't exist — see note) + beta wraps the outputs as { data: { outputs: [...] } }; v1 returns { outputs: [...] } with + msTenantId, which is mapped to the module's tenantId (null for a collated output). + A v1 401 renews the access token and retries once. + Used as the polling probe by Get-InforcerReportRun -Wait. Returns a typed result rather than throwing on the "not yet" case, so polling loops can branch cleanly. Note: a 404 alone cannot distinguish "run pending" from "run-id invalid". This helper treats any 404 as "not terminal yet" — the caller's timeout loop is what catches a bogus run id (it times out instead of completing). Run-id format validation happens - client-side via [guid]::TryParse before this is called. + client-side via [guid]::TryParse before this is called. On v1 a 404 also means the run + lies outside the API client's tenant scope. .PARAMETER RunId - The run identifier (GUID) returned from POST /beta/reports/runs. + The run identifier (GUID) returned from POST /reports/runs. .OUTPUTS PSCustomObject with members: RunId — the run identifier (string form of the guid) @@ -37,28 +42,31 @@ function Test-InforcerReportRunTerminal { return } - $apiKey = ConvertFrom-InforcerSecureString -SecureString $script:InforcerSession.ApiKey - if ([string]::IsNullOrWhiteSpace($apiKey)) { - Write-Error -Message 'API key is empty or invalid. Please reconnect.' ` - -ErrorId 'EmptyApiKey' -Category AuthenticationError - return - } - - $uri = $script:InforcerSession.BaseUrl + ('/beta/reports/runs/{0}/outputs' -f $RunId) - $headers = @{ - 'Inf-Api-Key' = $apiKey - 'Accept' = 'application/json' - } + $uri = Get-InforcerBaseUrl -Endpoint ('/reports/runs/{0}/outputs' -f $RunId) + $isV1 = $script:InforcerSession.ApiVersion -eq 'v1' Write-Verbose "Probing terminal state: GET $uri" - try { - $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck -UseBasicParsing -ErrorAction Stop - } catch { - $msg = Protect-InforcerApiKeyInText -Text $_.Exception.Message -ApiKey $apiKey - Write-Error -Message "Failed to probe run terminal state: $msg" ` - -ErrorId 'ProbeFailed' -Category ConnectionError - return + $forceRefresh = $false + while ($true) { + $headers = Get-InforcerAuthHeader -ForceRefresh:$forceRefresh + if (-not $headers) { return } + $headers['Accept'] = 'application/json' + + try { + $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck -UseBasicParsing -ErrorAction Stop + } catch { + $msg = Protect-InforcerApiKeyInText -Text $_.Exception.Message + Write-Error -Message "Failed to probe run terminal state: $msg" ` + -ErrorId 'ProbeFailed' -Category ConnectionError + return + } + if ($isV1 -and -not $forceRefresh -and [int]$response.StatusCode -eq 401) { + Write-Verbose 'HTTP 401: renewing the access token and retrying once.' + $forceRefresh = $true + continue + } + break } $statusCode = [int]$response.StatusCode @@ -68,8 +76,8 @@ function Test-InforcerReportRunTerminal { } if ($statusCode -eq 200) { - # Response shape (confirmed): { data: { outputs: [...] }, success: true, message, errors } - # Unwrap: response → .data → .outputs (the array of output records). + # beta: { data: { outputs: [...] }, success: true, ... }; v1: { outputs: [...] }. + # Unwrap: response → (.data →) .outputs (the array of output records). $outputs = @() if ($response.Content) { try { @@ -91,6 +99,9 @@ function Test-InforcerReportRunTerminal { Write-Verbose '200 response was not JSON-parseable; returning empty outputs.' } } + if ($isV1 -and $outputs.Count -gt 0) { + $outputs = @(ConvertFrom-InforcerV1Response -InputObject $outputs -ObjectType ReportOutput) + } return [PSCustomObject]@{ RunId = $RunId.ToString() IsTerminal = $true @@ -116,12 +127,20 @@ function Test-InforcerReportRunTerminal { try { $json = $response.Content | ConvertFrom-Json -ErrorAction Stop $msgProp = $json.PSObject.Properties['message'] - if ($msgProp) { $detail = $msgProp.Value -as [string] } + $titleProp = $json.PSObject.Properties['title'] + if ($msgProp) { + $detail = $msgProp.Value -as [string] + } elseif ($titleProp) { + $detail = $titleProp.Value -as [string] + $problemDetail = $json.PSObject.Properties['detail'].Value -as [string] + if ($problemDetail -and $problemDetail -ne $detail) { $detail = "${detail}: $problemDetail" } + } } catch { Write-Verbose 'Error response body was not JSON; using raw content.' } } if ([string]::IsNullOrWhiteSpace($detail)) { $detail = "HTTP $statusCode" } + $detail = Protect-InforcerApiKeyInText -Text $detail $errMsg = "Failed to probe run '$RunId': $detail" if ($correlationId) { $errMsg = "$errMsg [correlation-id: $correlationId]" } Write-Error -Message $errMsg -ErrorId "ProbeFailed_$statusCode" -Category ConnectionError diff --git a/module/Public/Get-InforcerAssessment.ps1 b/module/Public/Get-InforcerAssessment.ps1 index 931d6f6..e7a2215 100644 --- a/module/Public/Get-InforcerAssessment.ps1 +++ b/module/Public/Get-InforcerAssessment.ps1 @@ -2,10 +2,12 @@ .SYNOPSIS Retrieves available assessments from the Inforcer API. - Required API scope(s): Assessments.Read + Required API scope(s): Assessments.Read (API key); Tenant Assessment permission (API client). .DESCRIPTION Lists all assessments available for evaluation (e.g. Copilot Readiness, CIS Benchmarks, Essential Eight). Each assessment has an ID, name, description, tags, and type. + Works with both login types: an API key uses the legacy API, a client ID and secret + (Connect-Inforcer -ClientId) uses the v1 API. The output has the same properties either way. .PARAMETER Format Raw (default). .PARAMETER OutputType @@ -45,7 +47,7 @@ if (-not (Test-InforcerSession)) { Write-Verbose 'Retrieving assessments...' -$response = Invoke-InforcerApiRequest -Endpoint '/beta/assessments' -Method GET -OutputType $OutputType +$response = Invoke-InforcerApiRequest -Endpoint '/assessments' -Method GET -OutputType $OutputType -ObjectType Assessment if ($null -eq $response) { return } if ($OutputType -eq 'JsonObject') { diff --git a/module/Public/Get-InforcerReportRun.ps1 b/module/Public/Get-InforcerReportRun.ps1 index 0c92fce..430432b 100644 --- a/module/Public/Get-InforcerReportRun.ps1 +++ b/module/Public/Get-InforcerReportRun.ps1 @@ -2,9 +2,12 @@ .SYNOPSIS Lists report runs from the Inforcer Reports API, with optional polling and outputs embedding. - Required API scope(s): Reports.Read + Required API scope(s): Reports.Read (API key); Reports permission (API client). .DESCRIPTION - GET /beta/reports/runs returns the most recent runs (server-side cap: 500 items, 7-day window; + Works with both login types (API key: legacy API; client ID and secret: v1 API). The run + objects have the same properties either way (v1 reportRunId is exposed as runId). + + GET /reports/runs returns the most recent runs (server-side cap: 500 items, 7-day window; query parameters are silently ignored at the time of writing — filter client-side). Two known characteristics worth calling out before relying on this endpoint: @@ -22,7 +25,8 @@ Filter to a single run by GUID. Combined with -Wait, polls that run until terminal. .PARAMETER Wait Poll runs via GET /runs/{id}/outputs until terminal (200) or -TimeoutSeconds elapses. - Requires -RunId. + Requires -RunId. A 404 means "not ready yet" and, on v1, also "outside the API client's + tenant scope", so an unreadable run polls until the timeout. .PARAMETER IncludeOutputs For each returned run, fetch GET /runs/{id}/outputs and embed the array on .outputs. Adds one API call per run; use sparingly on large result sets. @@ -143,8 +147,8 @@ if ($Wait.IsPresent) { # --- Fetch list once, reuse for every piped RunId --- if (-not $listResponseFetched) { - Write-Verbose 'GET /beta/reports/runs' - $cachedListResponse = Invoke-InforcerApiRequest -Endpoint '/beta/reports/runs' -Method GET -OutputType PowerShellObject + Write-Verbose 'GET /reports/runs' + $cachedListResponse = Invoke-InforcerApiRequest -Endpoint '/reports/runs' -Method GET -OutputType PowerShellObject -ObjectType ReportRun $listResponseFetched = $true } $response = $cachedListResponse diff --git a/module/Public/Get-InforcerReportType.ps1 b/module/Public/Get-InforcerReportType.ps1 index 23ed292..aac1155 100644 --- a/module/Public/Get-InforcerReportType.ps1 +++ b/module/Public/Get-InforcerReportType.ps1 @@ -2,9 +2,10 @@ .SYNOPSIS Lists available report types from the Inforcer Reports API. - Required API scope(s): Reports.Read + Required API scope(s): Reports.Read (API key); Reports permission (API client). .DESCRIPTION - Retrieves the catalog of report types from GET /beta/reports/types. Each catalog entry + Retrieves the catalog of report types from GET /reports/types (API key: legacy API; client + ID and secret: v1 API, whose entries also carry singleTenantOnly). Each catalog entry describes a report's key, supported output formats, collatability, accepted parameters, and tags. The catalog is cached in the module-scoped variable so subsequent calls (and tab-completion lookups) are instant; pass -Force to refetch from the API. @@ -164,7 +165,7 @@ if (-not (Test-InforcerSession)) { # distorted by PSObject mutations applied during alias-adding. if ($OutputType -eq 'JsonObject' -or $Force -or $null -eq $script:InforcerReportTypeCache) { Write-Verbose 'Retrieving report types catalog...' - $response = Invoke-InforcerApiRequest -Endpoint '/beta/reports/types' -Method GET -OutputType $OutputType + $response = Invoke-InforcerApiRequest -Endpoint '/reports/types' -Method GET -OutputType $OutputType -ObjectType ReportType if ($null -eq $response) { return } if ($OutputType -eq 'JsonObject') { diff --git a/module/Public/Invoke-InforcerAssessment.ps1 b/module/Public/Invoke-InforcerAssessment.ps1 index fc1d551..fca30f0 100644 --- a/module/Public/Invoke-InforcerAssessment.ps1 +++ b/module/Public/Invoke-InforcerAssessment.ps1 @@ -2,7 +2,8 @@ .SYNOPSIS Runs an assessment against one or more tenants via the Inforcer API. - Required API scope(s): Tenants.Read + Assessments.Read + Assessments.Run + Required API scope(s): Tenants.Read + Assessments.Read + Assessments.Run (API key); + Tenants (read) + Tenant Assessment (run) permissions (API client). .DESCRIPTION Triggers an assessment run and returns detailed results including per-check scores, passes, violations, warnings, and framework metadata. @@ -10,9 +11,14 @@ API could not assess the check; they are reported but left out of the compliance score. Supports single-tenant, multi-tenant (all tenants), and subset modes. Use -MultiTenant to run against all tenants, or pass multiple values to -TenantId. + Works with both login types. With an API key (legacy API) tenants are identified by numeric + Client Tenant ID, GUID or name; with a client ID and secret (v1 API) by Microsoft tenant ID + (GUID) or name, and the TenantId on the output is the Microsoft tenant ID. v1 runs return + when the assessment completes, exactly like the legacy runs. When -OutputPath is specified with multi-tenant, generates an HTML matrix report. .PARAMETER TenantId - Tenant(s) to run the assessment against. Accepts numeric ID, GUID, or friendly name. + Tenant(s) to run the assessment against. Accepts numeric Client Tenant ID (API key only), + Microsoft tenant GUID, or friendly name. Aliases: -ClientTenantId, -MsTenantId. Pass multiple values for subset multi-tenant mode. Optional when -MultiTenant is used (defaults to all tenants). .PARAMETER AssessmentId @@ -60,7 +66,7 @@ function Invoke-InforcerAssessment { [OutputType([PSObject], [string], [System.IO.FileInfo])] param( [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object[]]$TenantId, [Parameter(Mandatory = $true)] @@ -90,12 +96,14 @@ if (-not $isMultiTenant -and ($null -eq $TenantId -or @($TenantId).Count -eq 0)) } # Fetch tenant list (needed for name resolution and multi-tenant) -$tenantData = @(Invoke-InforcerApiRequest -Endpoint '/beta/tenants' -Method GET -OutputType PowerShellObject) +$isV1 = $script:InforcerSession.ApiVersion -eq 'v1' +$tenantIdProperty = if ($isV1) { 'msTenantId' } else { 'clientTenantId' } +$tenantData = @(Get-InforcerTenantList -Refresh) # Resolve assessment $assessmentData = $null try { - $assessmentData = @(Invoke-InforcerApiRequest -Endpoint '/beta/assessments' -Method GET -OutputType PowerShellObject) + $assessmentData = @(Invoke-InforcerApiRequest -Endpoint '/assessments' -Method GET -OutputType PowerShellObject -ObjectType Assessment) $resolvedAssessmentId = Resolve-InforcerAssessmentId -AssessmentId $AssessmentId -AssessmentData $assessmentData } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidAssessmentId' -Category InvalidArgument @@ -117,11 +125,11 @@ foreach ($a in $assessmentData) { # Helper: resolve tenant friendly name from tenant data $resolveTenantName = { - param([int]$cid, [array]$data) + param($cid, [array]$data) foreach ($t in $data) { if ($t -is [PSObject]) { - $cidProp = $t.PSObject.Properties['clientTenantId'] - if ($cidProp -and [int]$cidProp.Value -eq $cid) { + $cidProp = $t.PSObject.Properties[$tenantIdProperty] + if ($cidProp -and $null -ne $cidProp.Value -and "$($cidProp.Value)" -ieq "$cid") { $fn = $t.PSObject.Properties['tenantFriendlyName'] $dn = $t.PSObject.Properties['tenantDnsName'] if ($fn -and -not [string]::IsNullOrWhiteSpace($fn.Value) -and $fn.Value -ne 'Tenant') { return $fn.Value } @@ -230,9 +238,9 @@ if ($isMultiTenant) { # All tenants foreach ($t in $tenantData) { if ($t -is [PSObject]) { - $cidProp = $t.PSObject.Properties['clientTenantId'] - if ($cidProp) { - $cid = [int]$cidProp.Value + $cidProp = $t.PSObject.Properties[$tenantIdProperty] + if ($cidProp -and $null -ne $cidProp.Value) { + $cid = if ($isV1) { "$($cidProp.Value)" } else { [int]$cidProp.Value } $tname = & $resolveTenantName $cid $tenantData [void]$tenantsToRun.Add(@{ Id = $cid; Name = $tname }) } @@ -268,7 +276,7 @@ if ($isMultiTenant) { $tenantIndex++ Write-Host "[$tenantIndex/$($tenantsToRun.Count)] " -NoNewline $response = Invoke-InforcerAssessmentRun ` - -ClientTenantId $tenant.Id ` + -TenantId $tenant.Id ` -ResolvedAssessmentId $resolvedAssessmentId ` -TenantDisplayName $tenant.Name ` -AssessmentDisplayName $assessmentDisplayName @@ -403,7 +411,7 @@ try { $tenantDisplayName = & $resolveTenantName $clientTenantId $tenantData $response = Invoke-InforcerAssessmentRun ` - -ClientTenantId $clientTenantId ` + -TenantId $clientTenantId ` -ResolvedAssessmentId $resolvedAssessmentId ` -TenantDisplayName $tenantDisplayName ` -AssessmentDisplayName $assessmentDisplayName diff --git a/module/Public/Invoke-InforcerReport.ps1 b/module/Public/Invoke-InforcerReport.ps1 index 513331d..0fab7e2 100644 --- a/module/Public/Invoke-InforcerReport.ps1 +++ b/module/Public/Invoke-InforcerReport.ps1 @@ -2,10 +2,12 @@ .SYNOPSIS Queues one or more Inforcer reports, waits for completion, and saves the outputs. - Required API scope(s): Reports.Read, Reports.Run + Required API scope(s): Reports.Read, Reports.Run (API key); Reports permission with run access + on every target tenant (API client). .DESCRIPTION - POST /beta/reports/runs submits a batch of (ReportType, OutputFormat) pairs against the - target tenants. By default the cmdlet runs synchronously: it polls the outputs endpoint + POST /reports/runs submits a batch of (ReportType, OutputFormat) pairs against the + target tenants. Works with both login types: an API key uses the legacy API (tenants as numeric + Client Tenant IDs), a client ID and secret uses the v1 API (tenants as Microsoft tenant IDs). By default the cmdlet runs synchronously: it polls the outputs endpoint until each run is terminal, downloads every output to the current directory (or -OutputPath), and emits a result object per saved file. @@ -23,7 +25,8 @@ otherwise renders all duplicates in the last-listed format. * -Collate is rejected when the catalog reports collatable:false on the type. * Unknown -Parameter keys are rejected — the server would otherwise silently ignore them. - * Each tenant identifier is validated and resolved to a numeric Client Tenant ID before POST. + * Each tenant identifier is validated and resolved before POST: to a numeric Client Tenant ID + with an API key, to a Microsoft tenant ID (GUID) with an API client. .PARAMETER ReportType One or more report type keys (e.g. CopilotAdoption, TenantAuditReport, Assessment). Case- insensitive. Get-InforcerReportType lists every available key. Pipeline-bindable by @@ -32,8 +35,10 @@ One or more output formats (csv, json, pdf, html, xlsx, ...). Must be supported by every paired report type or the cmdlet errors before submitting. Case-insensitive. .PARAMETER TenantId - One or more tenant identifiers (numeric Client Tenant ID, Microsoft Tenant GUID, or tenant - friendly/DNS name). Resolved via Resolve-InforcerTenantId. + One or more tenant identifiers. API key: numeric Client Tenant ID, Microsoft Tenant GUID, or + tenant friendly/DNS name. API client: Microsoft Tenant GUID or tenant friendly/DNS name + (numeric ids exist only on the legacy API). Resolved via Resolve-InforcerTenantId. + Aliases: -ClientTenantId, -MsTenantId. .PARAMETER ReportPeriod Optional integer days — applied as the report-period parameter when supported. CopilotAdoption and ShadowAiDetection auto-default to 30 when omitted. @@ -60,6 +65,7 @@ Created if it doesn't exist. .PARAMETER TimeoutSeconds Maximum total time to wait for any run to become terminal. Default: 600 (10 minutes). + The access token of an API client is renewed automatically during long waits. .PARAMETER PollIntervalSeconds Initial poll interval. Doubles up to a 15-second cap. Default: 2. .PARAMETER Format @@ -96,6 +102,8 @@ -NoWait → one object per run with { RunId, Status='queued', TenantId, ReportType, ... } -NoSave → one object per output with { RunId, OutputId, FileName, FileSize, ... } default → one object per saved output with { RunId, OutputId, FilePath, FileName, ... } + TenantId on the result is the Client Tenant ID (API key) or Microsoft tenant ID (API client); + 'Collated' for a cross-tenant output. .LINK https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#invoke-inforcerreport .LINK @@ -189,7 +197,7 @@ param( [string[]]$OutputFormat, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object[]]$TenantId, [Parameter(Mandatory = $false)] @@ -212,7 +220,7 @@ param( # If the user has explicitly typed -ReportType , # the completer is irrelevant — return empty. - $bound = @($fakeBoundParameters['ReportType']) + $bound = @($fakeBoundParameters['ReportType'] | Where-Object { $_ }) if ($bound.Count -gt 0) { $hasAssessment = $false foreach ($t in $bound) { @@ -231,16 +239,17 @@ param( ) } - # Snapshot session + cache + denial sentinel inside module scope. + # Snapshot session state + cache + denial sentinel inside module scope. Credentials never + # leave the module: the lazy fetch below gets its URI and header from the module's helpers. $state = & $module { [PSCustomObject]@{ - Connected = [bool]($script:InforcerSession -and $script:InforcerSession.ApiKey -and $script:InforcerSession.BaseUrl) + Connected = [bool](Test-InforcerSession) + IsV1 = [bool]($script:InforcerSession -and $script:InforcerSession.ApiVersion -eq 'v1') Cache = $script:InforcerAssessmentCache Denied = $script:InforcerAssessmentCacheDeniedAt - BaseUrl = if ($script:InforcerSession) { $script:InforcerSession.BaseUrl } else { $null } - ApiKey = if ($script:InforcerSession) { $script:InforcerSession.ApiKey } else { $null } } } + $deniedLabel = if ($state.IsV1) { '' } else { '' } if (-not $state.Connected) { return @( @@ -254,8 +263,9 @@ param( if ($state.Denied) { return @( [System.Management.Automation.CompletionResult]::new( - "''", '', 'ParameterValue', - 'The current API key lacks the Assessments.Read scope. Re-key with sufficient scope, then re-run Connect-Inforcer.' + "''", $deniedLabel, 'ParameterValue', + $(if ($state.IsV1) { 'The current API client lacks the Tenant Assessment permission. Grant it to the client in the inforcer portal, then re-run Connect-Inforcer.' } + else { 'The current API key lacks the Assessments.Read scope. Re-key with sufficient scope, then re-run Connect-Inforcer.' }) ) ) } @@ -265,30 +275,33 @@ param( if (-not $items) { # Lazy fetch with a tight 2-second budget so TAB never hangs. try { - $secure = $state.ApiKey - $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) - try { $apiKeyPlain = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } - finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } - - $uri = $state.BaseUrl + '/beta/assessments' - $hdr = @{ 'Inf-Api-Key' = $apiKeyPlain; 'Accept' = 'application/json' } - $resp = Invoke-RestMethod -Uri $uri -Method GET -Headers $hdr -TimeoutSec 2 -ErrorAction Stop - $items = @($resp.data) + $request = & $module { + $hdr = Get-InforcerAuthHeader -ErrorAction SilentlyContinue + if ($hdr) { + $hdr['Accept'] = 'application/json' + [PSCustomObject]@{ Uri = (Get-InforcerBaseUrl -Endpoint '/assessments'); Headers = $hdr } + } + } + if (-not $request) { return @() } + $resp = Invoke-RestMethod -Uri $request.Uri -Method GET -Headers $request.Headers -TimeoutSec 2 -ErrorAction Stop + $items = if ($state.IsV1) { @($resp.assessments) } else { @($resp.data) } & $module ([scriptblock]::Create('$script:InforcerAssessmentCache = $args[0]')) $items } catch { - # 401 (APIM gateway) or 403 → cache denial so subsequent TABs are instant. + # 403 (and, with an API key, a 401 from the APIM gateway) → cache denial so subsequent + # TABs are instant. A v1 401 means a bad token, not a missing permission. $sc = 0 if ($_.Exception.Response) { # Intentional empty catch — best-effort status-code extraction inside a TAB # completer must never throw. Falling back to $sc=0 → "not 401/403" branch. try { $sc = [int]$_.Exception.Response.StatusCode } catch { $null } } - if ($sc -eq 401 -or $sc -eq 403) { + if ($sc -eq 403 -or ($sc -eq 401 -and -not $state.IsV1)) { & $module { $script:InforcerAssessmentCacheDeniedAt = Get-Date } return @( [System.Management.Automation.CompletionResult]::new( - "''", '', 'ParameterValue', - 'The current API key lacks the Assessments.Read scope (or APIM rejected the subscription).' + "''", $deniedLabel, 'ParameterValue', + $(if ($state.IsV1) { 'The current API client lacks the Tenant Assessment permission.' } + else { 'The current API key lacks the Assessments.Read scope (or APIM rejected the subscription).' }) ) ) } @@ -414,6 +427,8 @@ if (-not (Test-InforcerSession)) { return } +$isV1 = $script:InforcerSession.ApiVersion -eq 'v1' + # Per-invocation progress ID — avoids collision with caller-side bars that may use -Id 1. # Uses a monotonically-increasing counter (atomic via Interlocked) so concurrent invocations # in the same runspace are guaranteed unique IDs. Random would have ~12% collision at 50 @@ -503,46 +518,57 @@ if ($entries.Count -eq 0) { # --- Resolve tenants --- # Reject obviously-wrong tenant types up-front. $true, hashtables, and arbitrary objects # would otherwise stringify to "True" / "System.Collections.Hashtable" and trigger a -# needless /beta/tenants lookup just to fail. +# needless tenant lookup just to fail. $invalidTenants = @($TenantId | Where-Object { $_ -isnot [int] -and $_ -isnot [long] -and $_ -isnot [string] -and $_ -isnot [guid] }) if ($invalidTenants.Count -gt 0) { $badTypes = ($invalidTenants | ForEach-Object { $_.GetType().Name } | Sort-Object -Unique) -join ', ' - Write-Error -Message "Tenant identifiers must be numeric Client Tenant ID, GUID, or tenant name (string). Received unsupported type(s): $badTypes." ` + $acceptedIds = if ($isV1) { 'Microsoft tenant ID (GUID) or tenant name (string)' } else { 'numeric Client Tenant ID, GUID, or tenant name (string)' } + Write-Error -Message "Tenant identifiers must be $acceptedIds. Received unsupported type(s): $badTypes." ` -ErrorId 'InvalidTenantIdType' -Category InvalidArgument return } -# Fast path: if every -TenantId is already a numeric Client Tenant ID, skip the -# /beta/tenants lookup entirely (it needs Tenants.Read scope which a Reports-only -# key won't have). Only when the user passes a name or GUID do we need the list. +# Fast path: skip the tenant list when every -TenantId can be used as-is (API key: a numeric +# Client Tenant ID; API client: a Microsoft tenant ID GUID). The list needs the tenants read +# permission, which a Reports-only credential won't have. Names (and GUIDs on the API-key +# login) need it. A numeric id on v1 is refused by Resolve-InforcerTenantId with a clear message. $tenantData = $null -$resolvedTenants = [System.Collections.Generic.List[int]]::new() +$resolvedTenants = [System.Collections.Generic.List[object]]::new() [int]$tenantParseTmp = 0 -$needsLookup = $false -foreach ($t in $TenantId) { - if ($t -isnot [int] -and -not [int]::TryParse(($t -as [string]), [ref]$tenantParseTmp)) { - $needsLookup = $true; break - } -} -if ($needsLookup) { - $tenantData = @(Invoke-InforcerApiRequest -Endpoint '/beta/tenants' -Method GET -OutputType PowerShellObject -ErrorVariable tenantListErr -ErrorAction SilentlyContinue) +$guidParseTmp = [guid]::Empty +$lookupInputs = @($TenantId | Where-Object { + $_ -isnot [int] -and -not [int]::TryParse(($_ -as [string]), [ref]$tenantParseTmp) -and + -not ($isV1 -and [guid]::TryParse(($_ -as [string]), [ref]$guidParseTmp)) +}) +if ($lookupInputs.Count -gt 0) { + $tenantData = @(Get-InforcerTenantList -Refresh -ErrorVariable tenantListErr -ErrorAction SilentlyContinue) if (-not $tenantData -or $tenantData.Count -eq 0) { $apiErr = if ($tenantListErr) { $tenantListErr[-1].Exception.Message } else { 'no response' } $isAuthFail = ($tenantListErr -and $tenantListErr.FullyQualifiedErrorId -match 'ApiRequestFailed_(401|403)') - # Only blame the values that actually need a lookup — passing numeric IDs alongside - # names should not have the user "re-pass numeric IDs" when they already did. - $unresolvable = @($TenantId | Where-Object { $_ -isnot [int] -and -not [int]::TryParse(($_ -as [string]), [ref]$tenantParseTmp) }) - $numericCount = @($TenantId).Count - $unresolvable.Count - $unresolvableList = $unresolvable -join "', '" - $mixedHint = if ($numericCount -gt 0) { - "$numericCount numeric tenant ID(s) were also provided and would have worked on their own; only the name/GUID inputs need Tenants.Read for resolution." - } else { '' } - $hint = if ($isAuthFail) { - "The API key was rejected when looking up tenant '$unresolvableList' against GET /beta/tenants (this endpoint requires the Tenants.Read scope, which a Reports-only key does not include). Pass the numeric Client Tenant ID for that tenant directly, or re-key with Tenants.Read to enable name / GUID resolution. $mixedHint".Trim() + # Only blame the values that actually need a lookup — passing directly usable ids alongside + # names should not have the user "re-pass ids" when they already did. + $unresolvableList = $lookupInputs -join "', '" + $directCount = @($TenantId).Count - $lookupInputs.Count + $hint = if ($isV1) { + $mixedHint = if ($directCount -gt 0) { + "$directCount tenant ID(s) were also provided as Microsoft tenant IDs and would have worked on their own; only the names need the Tenants permission for resolution." + } else { '' } + if ($isAuthFail) { + "The API client was rejected when looking up tenant '$unresolvableList' (listing tenants requires the Tenants permission, which a Reports-only API client does not include). Pass the Microsoft tenant ID (GUID) for that tenant directly, or grant the API client the Tenants permission to enable name resolution. $mixedHint".Trim() + } else { + "Could not retrieve the tenant list to resolve '$unresolvableList'. Underlying error: $apiErr. $mixedHint".Trim() + } } else { - "Could not retrieve the tenant list to resolve '$unresolvableList'. Underlying error: $apiErr. $mixedHint".Trim() + $mixedHint = if ($directCount -gt 0) { + "$directCount numeric tenant ID(s) were also provided and would have worked on their own; only the name/GUID inputs need Tenants.Read for resolution." + } else { '' } + if ($isAuthFail) { + "The API key was rejected when looking up tenant '$unresolvableList' against the tenant list (this endpoint requires the Tenants.Read scope, which a Reports-only key does not include). Pass the numeric Client Tenant ID for that tenant directly, or re-key with Tenants.Read to enable name / GUID resolution. $mixedHint".Trim() + } else { + "Could not retrieve the tenant list to resolve '$unresolvableList'. Underlying error: $apiErr. $mixedHint".Trim() + } } Write-Error -Message $hint -ErrorId 'TenantLookupUnavailable' -Category PermissionDenied return @@ -555,23 +581,32 @@ foreach ($t in $TenantId) { } else { Resolve-InforcerTenantId -TenantId $t } - if ($null -ne $resolvedId) { [void]$resolvedTenants.Add([int]$resolvedId) } + if ($null -ne $resolvedId) { + if ($isV1) { [void]$resolvedTenants.Add([string]$resolvedId) } else { [void]$resolvedTenants.Add([int]$resolvedId) } + } } catch { Write-Error -Message $_.Exception.Message -ErrorId 'TenantResolutionFailed' -Category InvalidArgument return } } -# Reject non-positive IDs and dedupe — duplicates in includeTenants either 400 server-side -# or silently dedupe, both of which produce confusing telemetry. Validate eagerly. -$invalidIds = @($resolvedTenants | Where-Object { $_ -le 0 }) -if ($invalidIds.Count -gt 0) { - Write-Error -Message ("Tenant ID(s) must be positive integers. Got: {0}." -f ($invalidIds -join ', ')) ` - -ErrorId 'InvalidTenantId' -Category InvalidArgument - return -} +# Dedupe — duplicates in the tenant list either 400 server-side or silently dedupe, both of +# which produce confusing telemetry. API key: reject non-positive ids first; v1 ids are GUIDs. $beforeDedup = $resolvedTenants.Count -$resolvedTenants = [System.Collections.Generic.List[int]]@($resolvedTenants | Sort-Object -Unique) +if ($isV1) { + $seenTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $uniqueTenants = [System.Collections.Generic.List[object]]::new() + foreach ($g in $resolvedTenants) { if ($seenTenants.Add($g)) { [void]$uniqueTenants.Add($g) } } + $resolvedTenants = $uniqueTenants +} else { + $invalidIds = @($resolvedTenants | Where-Object { $_ -le 0 }) + if ($invalidIds.Count -gt 0) { + Write-Error -Message ("Tenant ID(s) must be positive integers. Got: {0}." -f ($invalidIds -join ', ')) ` + -ErrorId 'InvalidTenantId' -Category InvalidArgument + return + } + $resolvedTenants = [System.Collections.Generic.List[int]]@($resolvedTenants | Sort-Object -Unique) +} if ($resolvedTenants.Count -lt $beforeDedup) { Write-Verbose ("Removed {0} duplicate tenant ID(s) before POST." -f ($beforeDedup - $resolvedTenants.Count)) } @@ -606,24 +641,25 @@ if ($wantSave) { } # --- Build POST body --- -$body = @{ - reports = @($entries) - tenants = @{ includeTenants = @($resolvedTenants) } +$body = if ($isV1) { + [ordered]@{ reports = @($entries); msTenantIds = @($resolvedTenants) } +} else { + @{ reports = @($entries); tenants = @{ includeTenants = @($resolvedTenants) } } } $bodyJson = $body | ConvertTo-Json -Depth 100 $summary = "Queue $($entries.Count) report(s) across $($resolvedTenants.Count) tenant(s)" -if (-not $PSCmdlet.ShouldProcess($summary, 'POST /beta/reports/runs')) { +if (-not $PSCmdlet.ShouldProcess($summary, 'POST /reports/runs')) { return } -Write-Verbose "POST /beta/reports/runs with $($entries.Count) report(s), tenants: $($resolvedTenants -join ', ')" +Write-Verbose "POST /reports/runs with $($entries.Count) report(s), tenants: $($resolvedTenants -join ', ')" Write-Progress -Id $progressId -Activity 'Invoke-InforcerReport' ` -Status "Queuing $($entries.Count) report(s) across $($resolvedTenants.Count) tenant(s)..." ` -PercentComplete 0 -$queueResponse = Invoke-InforcerApiRequest -Endpoint '/beta/reports/runs' -Method POST -Body $bodyJson +$queueResponse = Invoke-InforcerApiRequest -Endpoint '/reports/runs' -Method POST -Body $bodyJson -ObjectType ReportRun if ($null -eq $queueResponse) { Write-Progress -Id $progressId -Activity 'Invoke-InforcerReport' -Completed return @@ -657,8 +693,8 @@ $totalRuns = $runs.Count $completedRuns = 0 foreach ($run in $runs) { - # POST response shape is { data: { runId: "" } } unwrapped by Invoke-InforcerApiRequest; - # list endpoint also uses runId. Accept either runId or id for forward-compatibility. + # POST response is { runId } (beta, unwrapped by Invoke-InforcerApiRequest) or { reportRunId } + # (v1, mapped to runId). Accept either runId or id for forward-compatibility. $runIdProp = $run.PSObject.Properties['runId'] if (-not $runIdProp) { $runIdProp = $run.PSObject.Properties['id'] } $runIdValue = if ($runIdProp) { $runIdProp.Value -as [string] } else { $null } @@ -706,18 +742,19 @@ foreach ($run in $runs) { } if ($timedOut) { - Write-Error -Message "Timed out waiting for run $runIdValue to complete (waited $TimeoutSeconds s). Use Get-InforcerReportRun -RunId $runIdValue to check status." ` + $scopeNote = if ($isV1) { ' (a run outside the API client''s tenant scope also reports "not ready")' } else { '' } + Write-Error -Message "Timed out waiting for run $runIdValue to complete (waited $TimeoutSeconds s)$scopeNote. Use Get-InforcerReportRun -RunId $runIdValue to check status." ` -ErrorId 'PollTimeout' -Category OperationStopped continue } - # The outputs response shape (confirmed against api-uk.inforcer.com): - # { data: { outputs: [ { id, reportType, tenantId, format, sizeBytes }, ... ] } } - # Test-InforcerReportRunTerminal unwraps to the inner outputs array. + # Output records: { id, reportType, tenantId, format, sizeBytes } — beta unwraps + # { data: { outputs } }, v1 maps msTenantId to tenantId. Test-InforcerReportRunTerminal + # returns the inner outputs array. $outputs = @($outputsObj) if ($outputs.Count -eq 0) { - Write-Warning "Run $runIdValue is terminal but returned no outputs. Either the report produced no data, or no outputs are within this API key's tenant scope (collated outputs require the key to cover every tenant the run targeted)." + Write-Warning "Run $runIdValue is terminal but returned no outputs. Either the report produced no data, or no outputs are within this $(if ($isV1) { 'API client' } else { 'API key' })'s tenant scope (collated outputs require the $(if ($isV1) { 'client' } else { 'key' }) to cover every tenant the run targeted)." } elseif ($outputs.Count -lt $entries.Count -and $totalRuns -eq 1) { Write-Warning "Run $runIdValue produced $($outputs.Count) output(s) but $($entries.Count) report(s) were requested." } @@ -764,7 +801,7 @@ foreach ($run in $runs) { # { id, reportType, tenantId, format, sizeBytes } # No server-side fileName — the Content-Disposition header is the source of truth. # Streams to disk via -DestinationDirectory so large outputs don't load into memory. - $downloadEndpoint = "/beta/reports/runs/$runIdValue/outputs/$outId" + $downloadEndpoint = "/reports/runs/$runIdValue/outputs/$outId" $defaultName = ('{0}-{1}' -f $runIdValue, $outId) try { $download = Invoke-InforcerRawDownload -Endpoint $downloadEndpoint -DefaultFileName $defaultName ` @@ -784,17 +821,17 @@ foreach ($run in $runs) { $formatValue = $out.PSObject.Properties['format'] # Collated cross-tenant outputs report tenantId as 0, null, or an absent field - # (server behavior is inconsistent — see Reports-API-Feedback.md §16). When -Collate - # was requested on the run, OR the record has no positive tenantId, surface 'Collated' - # as a sentinel so it doesn't collide with the integer tenant-ID space. - $rawTenantId = $null - if ($tenantIdValue -and $null -ne $tenantIdValue.Value) { - $rawTenantId = $tenantIdValue.Value -as [int] - } - $tenantOut = if ($Collate.IsPresent -or $null -eq $rawTenantId -or $rawTenantId -le 0) { + # (server behavior is inconsistent — see Reports-API-Feedback.md §16; v1 always sends null). + # When -Collate was requested on the run, OR the record has no usable tenant id, surface + # 'Collated' as a sentinel so it doesn't collide with the tenant-ID space. + $rawTenantId = if ($tenantIdValue) { $tenantIdValue.Value } else { $null } + $tenantOut = if ($Collate.IsPresent -or $null -eq $rawTenantId -or [string]::IsNullOrWhiteSpace("$rawTenantId")) { 'Collated' + } elseif ($isV1) { + "$rawTenantId" } else { - $rawTenantId + $numericTenantId = $rawTenantId -as [int] + if ($null -eq $numericTenantId -or $numericTenantId -le 0) { 'Collated' } else { $numericTenantId } } $result = [PSCustomObject][ordered]@{ diff --git a/module/Public/Save-InforcerReportOutput.ps1 b/module/Public/Save-InforcerReportOutput.ps1 index e311f1a..8fe98c8 100644 --- a/module/Public/Save-InforcerReportOutput.ps1 +++ b/module/Public/Save-InforcerReportOutput.ps1 @@ -2,9 +2,10 @@ .SYNOPSIS Downloads a report output to disk. - Required API scope(s): Reports.Read + Required API scope(s): Reports.Read (API key); Reports permission (API client). .DESCRIPTION - GET /beta/reports/runs/{runId}/outputs/{outputId} returns the raw bytes plus a + GET /reports/runs/{runId}/outputs/{outputId} (legacy API for an API-key login, v1 API for a + client ID and secret login) returns the raw bytes plus a Content-Disposition filename. This cmdlet writes those bytes to -OutputPath using the server-suggested filename (sanitized) and emits a result object per saved file. @@ -23,6 +24,8 @@ Pipeline-bindable. Same as ReportType — propagated from the upstream record when piped. .PARAMETER TenantId Pipeline-bindable. Same as ReportType — propagated from the upstream record when piped. + API key: numeric Client Tenant ID; client ID and secret: Microsoft tenant ID (GUID). Collated + outputs carry no tenant ($null). Aliases: -ClientTenantId, -MsTenantId. .PARAMETER OutputPath Directory where downloaded outputs are written. Defaults to the current working directory. Created if it doesn't exist. @@ -78,7 +81,7 @@ param( [string]$OutputFormat, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, [Parameter(Mandatory = $false)] @@ -133,7 +136,7 @@ process { } $runIdStr = $RunId.ToString() - $endpoint = "/beta/reports/runs/$runIdStr/outputs/$OutputId" + $endpoint = "/reports/runs/$runIdStr/outputs/$OutputId" # When -FileName is supplied the user's choice wins (after filesystem-safety sanitization). # Otherwise the server's Content-Disposition filename is used (via Invoke-InforcerRawDownload). From 9c2653f5814bd9cba9680e484d612a2e97a86d6d Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:19:44 +0100 Subject: [PATCH 04/27] test: keep the tenant-lookup mock non-terminating under ErrorActionPreference Stop Co-Authored-By: Claude Opus 5.5 --- Tests/V1.Reports.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/V1.Reports.Tests.ps1 b/Tests/V1.Reports.Tests.ps1 index 2c5a4e4..aace05a 100644 --- a/Tests/V1.Reports.Tests.ps1 +++ b/Tests/V1.Reports.Tests.ps1 @@ -215,7 +215,7 @@ Describe 'Invoke-InforcerReport request body' { } It 'lookup failure hint names the API client permission on v1 and the API scope on beta' { - Mock -ModuleName InforcerCommunity Get-InforcerTenantList { Write-Error -Message 'denied' -ErrorId 'ApiRequestFailed_403' -Category ConnectionError } + Mock -ModuleName InforcerCommunity Get-InforcerTenantList { Write-Error -Message 'denied' -ErrorId 'ApiRequestFailed_403' -Category ConnectionError -ErrorAction Continue } Set-ReportsTestSession -Mode v1 Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 'Contoso Example' -NoWait -ErrorVariable v1Err -ErrorAction SilentlyContinue ($v1Err | Where-Object { $_.FullyQualifiedErrorId -like 'TenantLookupUnavailable*' } | Select-Object -First 1).Exception.Message | From 2f6071c9a7cbe929dead129b666b870570b4693d Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:20:46 +0100 Subject: [PATCH 05/27] fix: numeric tenant ids on v1 always reach the caller's catch ThrowTerminatingError is silenced when the caller passes -ErrorAction SilentlyContinue, so cmdlets wrapping Resolve-InforcerTenantId in try/catch returned nothing instead of their InvalidTenantId error. A plain throw of the ErrorRecord keeps the ErrorId and is always catchable. Co-Authored-By: Claude Opus 5.5 --- module/Private/Resolve-InforcerTenantId.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Private/Resolve-InforcerTenantId.ps1 b/module/Private/Resolve-InforcerTenantId.ps1 index 2efc94e..a225e2d 100644 --- a/module/Private/Resolve-InforcerTenantId.ps1 +++ b/module/Private/Resolve-InforcerTenantId.ps1 @@ -34,7 +34,7 @@ function Resolve-InforcerTenantId { $parsedInt = 0 if ([int]::TryParse($tenantIdString, [ref]$parsedInt)) { if ($isV1) { - $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new( + throw ([System.Management.Automation.ErrorRecord]::new( [System.ArgumentException]::new("Numeric tenant id '$tenantIdString' only works when you connect with an API key. With client credentials, pass the Microsoft tenant ID (GUID) or the tenant name; run Get-InforcerTenant to list them."), 'TenantIdNumericNotSupportedOnV1', [System.Management.Automation.ErrorCategory]::InvalidArgument, From e6727c648d98e4d2340ecfc154f1c9e2344e70aa Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:21:00 +0100 Subject: [PATCH 06/27] feat: support v1 for tenants, baselines and alignment details - Get-InforcerTenant -Detailed fetches per-tenant details on v1 (no-op on beta, whose list is already rich); -TenantId returns the detailed tenant - Get-InforcerBaseline and Get-InforcerAlignmentDetails work on v1, including -Tag, baseline members and the scores table - Tenant lookups reuse the cached tenant list instead of refetching - Format views show the Microsoft tenant ID when there is no client tenant ID - -TenantId accepts the MsTenantId alias for pipeline binding Co-Authored-By: Claude Opus 5.5 --- Tests/V1.Tenants.Tests.ps1 | 371 ++++++++++++++++++ module/InforcerCommunity.Format.ps1xml | 51 ++- module/Private/Resolve-InforcerBaselineId.ps1 | 4 +- .../Public/Get-InforcerAlignmentDetails.ps1 | 217 +++++----- module/Public/Get-InforcerBaseline.ps1 | 50 ++- module/Public/Get-InforcerTenant.ps1 | 144 ++++--- 6 files changed, 665 insertions(+), 172 deletions(-) create mode 100644 Tests/V1.Tenants.Tests.ps1 diff --git a/Tests/V1.Tenants.Tests.ps1 b/Tests/V1.Tenants.Tests.ps1 new file mode 100644 index 0000000..ba7b52d --- /dev/null +++ b/Tests/V1.Tenants.Tests.ps1 @@ -0,0 +1,371 @@ +# V1.Tenants.Tests.ps1 +# Get-InforcerTenant, Get-InforcerBaseline, Get-InforcerAlignmentDetails and Resolve-InforcerBaselineId +# on both logins. Invoke-InforcerHttpRequest is mocked, so the real request, unwrap and v1 mapping run. +# Fixtures are anonymised (zero GUIDs, example names); no network calls. + +BeforeAll { + Remove-Module -Name 'InforcerCommunity' -ErrorAction SilentlyContinue + Import-Module (Join-Path $PSScriptRoot '../module/InforcerCommunity.psd1') -Force + $script:M = Get-Module InforcerCommunity + + $script:T1 = '00000000-0000-0000-0000-000000000001' + $script:T2 = '00000000-0000-0000-0000-000000000002' + $script:B1 = '00000000-0000-0000-0000-0000000000b1' + $script:B2 = '00000000-0000-0000-0000-0000000000b2' + + function global:Reset-V1TenantsRoutes { + $global:V1TenantsRoutes = [ordered]@{ + # v1 (client credentials) + '/v1/tenants$' = '{"tenants":[{"msTenantId":"00000000-0000-0000-0000-000000000001","name":"Contoso","dnsName":"contoso.onmicrosoft.com"},{"msTenantId":"00000000-0000-0000-0000-000000000002","name":"Fabrikam","dnsName":"fabrikam.onmicrosoft.com"}]}' + '/v1/tenants/00000000-0000-0000-0000-000000000001$' = '{"msTenantId":"00000000-0000-0000-0000-000000000001","name":"Contoso","dnsName":"contoso.onmicrosoft.com","secureScore":50.5,"isBaseline":true,"lastBackupTimestamp":"2026-01-01T00:00:00Z","recentChanges":0,"policyDiff":null,"tags":[{"name":"Production","description":"Live tenants"}],"alignmentSummaries":[],"licenses":[{"sku":"SPE_E5"}],"authenticationMode":"Standard"}' + '/v1/tenants/00000000-0000-0000-0000-000000000002$' = '{"msTenantId":"00000000-0000-0000-0000-000000000002","name":"Fabrikam","dnsName":"fabrikam.onmicrosoft.com","secureScore":40,"isBaseline":false,"lastBackupTimestamp":"2026-01-01T00:00:00Z","recentChanges":1,"policyDiff":null,"tags":[{"name":"Test","description":""}],"alignmentSummaries":[{"alignedBaselineId":"00000000-0000-0000-0000-0000000000b1","alignedBaselineMsTenantId":"00000000-0000-0000-0000-000000000001","alignedBaselineName":"Contoso Baseline","alignmentScore":80.5,"alignedThreshold":90,"semiAlignedThreshold":70,"lastAlignmentTimestamp":"2026-01-02T00:00:00Z"}],"licenses":[{"sku":"SPE_E3"},{"sku":"EMS"}],"authenticationMode":"Standard"}' + '/v1/baselines$' = '{"baselines":[{"id":"00000000-0000-0000-0000-0000000000b1","name":"Contoso Baseline","description":"","baselineTenant":{"msTenantId":"00000000-0000-0000-0000-000000000001","name":"Contoso","dnsName":"contoso.onmicrosoft.com"},"mode":"Monitor","alignedThreshold":90,"semiAlignedThreshold":70,"autoAddNewPolicies":false,"isComplete":true,"isShared":false,"isSharedCopy":false,"members":[{"msTenantId":"00000000-0000-0000-0000-000000000002","name":"Fabrikam","dnsName":"fabrikam.onmicrosoft.com","lastAlignmentScore":80.5,"lastAlignmentTimestamp":"2026-01-02T00:00:00Z"}],"items":[]},{"id":"00000000-0000-0000-0000-0000000000b2","name":"Other Baseline","description":"","baselineTenant":{"msTenantId":"00000000-0000-0000-0000-000000000003","name":"Northwind","dnsName":"northwind.onmicrosoft.com"},"mode":"Monitor","alignedThreshold":90,"semiAlignedThreshold":70,"autoAddNewPolicies":false,"isComplete":true,"isShared":true,"isSharedCopy":false,"members":[{"msTenantId":"00000000-0000-0000-0000-000000000001","name":"Contoso","dnsName":"contoso.onmicrosoft.com","lastAlignmentScore":100,"lastAlignmentTimestamp":"2026-01-02T00:00:00Z"}],"items":[]}]}' + '/v1/alignmentScores$' = '{"alignmentScores":[{"msTenantId":"00000000-0000-0000-0000-000000000002","tenantName":"Fabrikam","alignedBaselineId":"00000000-0000-0000-0000-0000000000b1","alignedBaselineName":"Contoso Baseline","alignmentScore":80.5,"lastAlignmentTimestamp":"2026-01-02T00:00:00Z"},{"msTenantId":"00000000-0000-0000-0000-000000000001","tenantName":"Contoso","alignedBaselineId":"00000000-0000-0000-0000-0000000000b2","alignedBaselineName":"Other Baseline","alignmentScore":100,"lastAlignmentTimestamp":"2026-01-02T00:00:00Z"}]}' + '/v1/tenants/[^/]+/alignmentDetails\?baselineId=' = '{"completedAt":"2026-01-02T00:00:00Z","baseline":{"id":"00000000-0000-0000-0000-0000000000b1","name":"Contoso Baseline"},"baselineDataTimestamp":"2026-01-01T00:00:00Z","tenantDataTimestamp":"2026-01-01T00:00:00Z","alignmentScore":80.5,"metrics":{"totalPolicies":2,"matched":1,"matchedWithAcceptedDeviations":0,"deviated":1,"missingFromTenant":0,"additionalInTenant":0},"policies":{"matched":[{"id":"p1","policyTypeId":1,"policyTypeName":"Settings Catalog","displayName":"Policy A","friendlyName":"Policy A","description":"","readOnly":false,"product":"Intune","primaryGroup":"Devices","secondaryGroup":"Windows","platform":"Windows","tags":[{"name":"Tier1","description":""}],"tenantPolicies":[],"variables":[],"acceptedDeviations":[],"similarPolicies":[]}],"matchedWithAcceptedDeviations":[],"deviated":[{"id":"p2","policyTypeId":1,"policyTypeName":"Settings Catalog","displayName":"Policy B","friendlyName":"Policy B","description":"","readOnly":false,"product":"Intune","primaryGroup":"Devices","secondaryGroup":"Windows","platform":"Windows","tags":[],"tenantPolicies":[],"variables":[],"acceptedDeviations":[],"similarPolicies":[]}],"missingFromTenant":[],"additionalInTenant":[]}}' + # beta (API key) + '/beta/tenants$' = '{"success":true,"data":[{"clientTenantId":1,"msTenantId":"00000000-0000-0000-0000-000000000001","tenantFriendlyName":"Contoso","tenantDnsName":"contoso.onmicrosoft.com","licenses":[{"sku":"SPE_E5"}],"secureScore":50.5,"isBaseline":true,"lastBackupTimestamp":"2026-01-01T00:00:00Z","recentChanges":0,"policyDiff":null,"tags":[],"alignmentSummaries":[{"alignedBaselineId":"00000000-0000-0000-0000-0000000000b2","alignedBaselineName":"Other Baseline","alignedBaselineTenantId":3,"alignmentScore":100,"alignedThreshold":90,"semiAlignedThreshold":70,"lastAlignmentDateTime":"2026-01-02T00:00:00Z"}]},{"clientTenantId":2,"msTenantId":"00000000-0000-0000-0000-000000000002","tenantFriendlyName":"Fabrikam","tenantDnsName":"fabrikam.onmicrosoft.com","licenses":[{"sku":"SPE_E3"},{"sku":"EMS"}],"secureScore":40,"isBaseline":false,"lastBackupTimestamp":"2026-01-01T00:00:00Z","recentChanges":1,"policyDiff":null,"tags":[],"alignmentSummaries":[{"alignedBaselineId":"00000000-0000-0000-0000-0000000000b1","alignedBaselineName":"Contoso Baseline","alignedBaselineTenantId":1,"alignmentScore":80.5,"alignedThreshold":90,"semiAlignedThreshold":70,"lastAlignmentDateTime":"2026-01-02T00:00:00Z"}]},{"clientTenantId":2,"msTenantId":"00000000-0000-0000-0000-000000000002","tenantFriendlyName":"Fabrikam","tenantDnsName":"fabrikam.onmicrosoft.com","licenses":[],"secureScore":40,"isBaseline":false,"lastBackupTimestamp":null,"recentChanges":0,"policyDiff":null,"tags":[],"alignmentSummaries":[]}]}' + '/beta/baselines$' = '{"success":true,"data":[{"id":"00000000-0000-0000-0000-0000000000b1","name":"Contoso Baseline","baselineClientTenantId":1,"baselineMsTenantId":"00000000-0000-0000-0000-000000000001","baselineTenantFriendlyName":"Contoso","baselineTenantDnsName":"contoso.onmicrosoft.com","mode":"Monitor","alignedThreshold":90,"semiAlignedThreshold":70,"autoAddNewPolicies":false,"isComplete":true,"isShared":false,"members":[{"clientTenantId":2,"msTenantId":"00000000-0000-0000-0000-000000000002","tenantFriendlyName":"Fabrikam","tenantDnsName":"fabrikam.onmicrosoft.com"}],"items":[]},{"id":"00000000-0000-0000-0000-0000000000b2","name":"Other Baseline","baselineClientTenantId":3,"baselineMsTenantId":"00000000-0000-0000-0000-000000000003","baselineTenantFriendlyName":"Northwind","baselineTenantDnsName":"northwind.onmicrosoft.com","mode":"Monitor","alignedThreshold":90,"semiAlignedThreshold":70,"autoAddNewPolicies":false,"isComplete":true,"isShared":true,"members":[{"clientTenantId":1,"msTenantId":"00000000-0000-0000-0000-000000000001","tenantFriendlyName":"Contoso","tenantDnsName":"contoso.onmicrosoft.com"}],"items":[]}]}' + '/beta/alignmentScores$' = '{"success":true,"data":[{"tenantId":2,"tenantFriendlyName":"Fabrikam","score":80.5,"baselineGroupId":"00000000-0000-0000-0000-0000000000b1","baselineGroupName":"Contoso Baseline","lastComparisonDateTime":"2026-01-02T00:00:00Z"},{"tenantId":1,"tenantFriendlyName":"Contoso","score":100,"baselineGroupId":"00000000-0000-0000-0000-0000000000b2","baselineGroupName":"Other Baseline","lastComparisonDateTime":"2026-01-02T00:00:00Z"}]}' + '/beta/tenants/\d+/alignmentDetails\?customBaselineId=' = '{"success":true,"data":{"metrics":{"totalPolicies":2,"matchedPolicies":1,"matchedWithAcceptedDeviations":0,"deviatedPolicies":1,"recommendedPoliciesFromBaseline":0,"customerOnlyPolicies":0},"completedAt":"2026-01-02T00:00:00Z","alignment":{"alignmentScore":80.5,"matchedPolicies":[{"policyName":"Policy A","product":"Intune","primaryGroup":"Devices","secondaryGroup":"Windows","inforcerPolicyTypeName":"Settings Catalog","policyTags":[{"name":"Tier1"}]}],"matchedWithAcceptedDeviations":[],"deviatedUnaccepted":[{"policyName":"Policy B","product":"Intune","primaryGroup":"Devices","secondaryGroup":"Windows","inforcerPolicyTypeName":"Settings Catalog","policyTags":[]}],"missingFromSubjectUnaccepted":[],"additionalInSubjectUnaccepted":[]}}}' + } + } + + function global:Get-V1TenantsResponse([string]$Uri) { + foreach ($pattern in $global:V1TenantsRoutes.Keys) { + if ($Uri -notmatch $pattern) { continue } + $route = $global:V1TenantsRoutes[$pattern] + $status = 200 + if ($route -is [hashtable]) { $status = $route.Status; $route = $route.Body } + return [pscustomobject]@{ StatusCode = $status; Body = ($route | ConvertFrom-Json -Depth 100); Headers = @{} } + } + [pscustomobject]@{ StatusCode = 404; Body = [pscustomobject]@{ title = 'Not Found'; status = 404 }; Headers = @{} } + } + + function global:Set-V1TenantsSession { + param([switch]$Beta) + & (Get-Module InforcerCommunity) { + param($Beta) + $script:InforcerTenantListCache = $null + if ($Beta) { + $script:InforcerSession = @{ + AuthMode = 'ApiKey'; ApiVersion = 'beta'; ApiHost = 'https://api.example.invalid' + BaseUrl = 'https://api.example.invalid/api'; Region = 'uk'; ConnectedAt = Get-Date + ApiKey = (ConvertTo-SecureString 'beta-test-key-value' -AsPlainText -Force) + } + } else { + $script:InforcerSession = @{ + AuthMode = 'ClientCredentials'; ApiVersion = 'v1'; ApiHost = 'https://api.example.invalid' + BaseUrl = 'https://api.example.invalid'; Region = 'uk'; ConnectedAt = Get-Date + ClientId = 'client-id-example' + ClientSecret = (ConvertTo-SecureString 'client-secret-example-value' -AsPlainText -Force) + AccessToken = (ConvertTo-SecureString 'access-token-example-value' -AsPlainText -Force) + TokenExpiresAt = [datetime]::UtcNow.AddMinutes(50) + } + } + } $Beta.IsPresent + } +} + +AfterAll { + foreach ($f in 'Reset-V1TenantsRoutes', 'Get-V1TenantsResponse', 'Set-V1TenantsSession') { Remove-Item "function:global:$f" -ErrorAction SilentlyContinue } + Remove-Variable -Name V1TenantsRoutes -Scope Global -ErrorAction SilentlyContinue + & (Get-Module InforcerCommunity) { $script:InforcerSession = $null; $script:InforcerTenantListCache = $null } +} + +Describe 'Tenants and alignment on both logins' { +BeforeEach { + Reset-V1TenantsRoutes + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { Get-V1TenantsResponse $Request.Uri } +} + +Describe 'Get-InforcerTenant' { + It 'v1: the plain list returns the light objects and makes no per-tenant calls' { + Set-V1TenantsSession + $r = @(Get-InforcerTenant) + $r.Count | Should -Be 2 + $r[0].TenantFriendlyName | Should -BeExactly 'Contoso' + $r[0].TenantDnsName | Should -BeExactly 'contoso.onmicrosoft.com' + $r[0].MsTenantId | Should -Be $script:T1 + $r[0].PSObject.Properties['clientTenantId'] | Should -BeNullOrEmpty + $r[0].PSObject.TypeNames[0] | Should -Be 'InforcerCommunity.Tenant' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly + } + + It 'v1: -Detailed loads each tenant record with canonical names' { + Set-V1TenantsSession + $r = @(Get-InforcerTenant -Detailed) + $r.Count | Should -Be 2 + $r[0].IsBaseline | Should -BeTrue + $r[0].licenses | Should -BeExactly 'SPE_E5' + $r[1].licenses | Should -BeExactly 'SPE_E3, EMS' + $r[0].AuthenticationMode | Should -BeExactly 'Standard' + $r[1].alignmentSummaries[0].PSObject.Properties.Name | Should -Contain 'lastAlignmentDateTime' + $r[0].PSObject.Properties['name'] | Should -BeNullOrEmpty + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 2 -Exactly -ParameterFilter { $Request.Uri -match '/v1/tenants/[0-9a-f-]{36}$' } + } + + It 'v1: -Detailed -OutputType JsonObject returns a JSON array with the raw licenses array' { + Set-V1TenantsSession + $json = Get-InforcerTenant -Detailed -OutputType JsonObject + $json.TrimStart() | Should -BeLike '`[*' + $parsed = @($json | ConvertFrom-Json -Depth 100) + $parsed.Count | Should -Be 2 + @($parsed[1].licenses).Count | Should -Be 2 + $parsed[0].tenantFriendlyName | Should -BeExactly 'Contoso' + } + + It 'v1: -TenantId returns the single detailed tenant' { + Set-V1TenantsSession + $r = @(Get-InforcerTenant -TenantId 'Fabrikam') + $r.Count | Should -Be 1 + $r[0].MsTenantId | Should -Be $script:T2 + $r[0].IsBaseline | Should -BeFalse + $r[0].PSObject.Properties['isBaseline'] | Should -Not -BeNullOrEmpty + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly -ParameterFilter { $Request.Uri -match '/v1/tenants/00000000-0000-0000-0000-000000000002$' } + } + + It 'v1: an unknown GUID is TenantNotFound without a detail call; a numeric id is InvalidTenantId' { + Set-V1TenantsSession + $null = Get-InforcerTenant -TenantId '00000000-0000-0000-0000-000000000009' -ErrorVariable e1 -ErrorAction SilentlyContinue + $e1[0].FullyQualifiedErrorId | Should -BeLike 'TenantNotFound*' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 0 -Exactly -ParameterFilter { $Request.Uri -match '/v1/tenants/[0-9a-f-]{36}$' } + $null = Get-InforcerTenant -TenantId 482 -ErrorVariable e2 -ErrorAction SilentlyContinue + $e2[0].FullyQualifiedErrorId | Should -BeLike 'InvalidTenantId*' + $e2[0].Exception.Message | Should -Match 'API key' + } + + It 'v1: -Detailed keeps the summary and warns when one tenant record fails' { + Set-V1TenantsSession + $global:V1TenantsRoutes['/v1/tenants/00000000-0000-0000-0000-000000000002$'] = @{ Status = 500; Body = '{"title":"Server error","status":500}' } + $r = @(Get-InforcerTenant -Detailed -WarningVariable w -WarningAction SilentlyContinue) + $r.Count | Should -Be 2 + $r[1].PSObject.Properties['isBaseline'] | Should -BeNullOrEmpty + $r[1].TenantFriendlyName | Should -BeExactly 'Fabrikam' + @($w).Count | Should -Be 1 + "$($w[0])" | Should -Match 'Fabrikam' + } + + It 'beta: -Detailed is a no-op and makes no per-tenant calls' { + Set-V1TenantsSession -Beta + $plain = Get-InforcerTenant -OutputType JsonObject + $detailed = Get-InforcerTenant -Detailed -OutputType JsonObject + $detailed | Should -BeExactly $plain + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 0 -Exactly -ParameterFilter { $Request.Uri -match '/beta/tenants/\d+$' } + } + + It 'beta: PowerShell output dedupes by ClientTenantId; JSON keeps the API list; -TenantId numeric filters' { + Set-V1TenantsSession -Beta + $r = @(Get-InforcerTenant) + $r.ClientTenantId | Should -Be @(1, 2) + $r[1].licenses | Should -BeExactly 'SPE_E3, EMS' + @(Get-InforcerTenant -OutputType JsonObject | ConvertFrom-Json).Count | Should -Be 3 + @(Get-InforcerTenant -TenantId 2).TenantFriendlyName | Should -Be @('Fabrikam') + } + + It 'never modifies the cached tenant list objects' { + Set-V1TenantsSession -Beta + $null = Get-InforcerTenant + $cached = & $script:M { $script:InforcerTenantListCache.Tenants } + $cached[0].licenses | Should -Not -BeOfType [string] + $cached[0].licenses[0].sku | Should -BeExactly 'SPE_E5' + $cached[0].PSObject.TypeNames | Should -Not -Contain 'InforcerCommunity.Tenant' + } + + It 'documents -Detailed in help' { + (Get-Help Get-InforcerTenant -Parameter Detailed).Name | Should -Be 'Detailed' + } +} + +Describe 'Pipeline binding of -TenantId (ClientTenantId / MsTenantId aliases)' { + It 'declares both aliases on every -TenantId parameter in this area' { + foreach ($name in 'Get-InforcerTenant', 'Get-InforcerBaseline', 'Get-InforcerAlignmentDetails') { + $aliases = (Get-Command $name).Parameters['TenantId'].Aliases + $aliases | Should -Contain 'ClientTenantId' + $aliases | Should -Contain 'MsTenantId' + } + } + + It 'beta: an object carrying clientTenantId and msTenantId binds the clientTenantId' { + Set-V1TenantsSession -Beta + # clientTenantId 2 is Fabrikam; msTenantId T1 is Contoso. Binding the GUID would return Contoso. + $r = @([pscustomobject]@{ clientTenantId = 2; msTenantId = $script:T1 } | Get-InforcerTenant) + $r.TenantFriendlyName | Should -Be @('Fabrikam') + } + + It 'v1: an object carrying only msTenantId binds it' { + Set-V1TenantsSession + $r = @([pscustomobject]@{ msTenantId = $script:T2; tenantFriendlyName = 'Fabrikam' } | Get-InforcerTenant) + $r.Count | Should -Be 1 + $r[0].MsTenantId | Should -Be $script:T2 + $r[0].IsBaseline | Should -BeFalse + } + + It 'pipes Get-InforcerTenant output into Get-InforcerBaseline in both modes' { + Set-V1TenantsSession + @(Get-InforcerTenant -TenantId 'Fabrikam' | Get-InforcerBaseline).BaselineId | Should -Be @($script:B1) + Set-V1TenantsSession -Beta + @(Get-InforcerTenant -TenantId 2 | Get-InforcerBaseline).BaselineId | Should -Be @($script:B1) + } +} + +Describe 'Get-InforcerBaseline' { + It 'v1: maps the owner and members to the module names' { + Set-V1TenantsSession + $r = @(Get-InforcerBaseline) + $r.Count | Should -Be 2 + $r[0].BaselineMsTenantId | Should -Be $script:T1 + $r[0].BaselineTenantFriendlyName | Should -BeExactly 'Contoso' + $r[0].PSObject.Properties['baselineTenant'] | Should -BeNullOrEmpty + $r[0].PSObject.Properties['baselineClientTenantId'] | Should -BeNullOrEmpty + $r[0].members[0].tenantFriendlyName | Should -BeExactly 'Fabrikam' + $r[0].BaselineName | Should -BeExactly 'Contoso Baseline' + } + + It 'v1: -TenantId filters on owner and member msTenantId, by GUID and by name' { + Set-V1TenantsSession + @(Get-InforcerBaseline -TenantId $script:T2).id | Should -Be @($script:B1) + @(Get-InforcerBaseline -TenantId 'Contoso').id | Should -Be @($script:B1, $script:B2) + } + + It 'v1: -TenantId with -OutputType JsonObject returns filtered JSON with module names' { + Set-V1TenantsSession + $parsed = @(Get-InforcerBaseline -TenantId $script:T2 -OutputType JsonObject | ConvertFrom-Json -Depth 100) + $parsed.Count | Should -Be 1 + $parsed[0].baselineTenantFriendlyName | Should -BeExactly 'Contoso' + $parsed[0].members[0].msTenantId | Should -Be $script:T2 + } + + It 'beta: -TenantId numeric still filters on clientTenantId' { + Set-V1TenantsSession -Beta + @(Get-InforcerBaseline -TenantId 2).id | Should -Be @($script:B1) + @(Get-InforcerBaseline -TenantId 1).id | Should -Be @($script:B1, $script:B2) + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 0 -Exactly -ParameterFilter { $Request.Uri -match '/v1/' } + } +} + +Describe 'Resolve-InforcerBaselineId' { + It 'v1: resolves a baseline name case-insensitively through the neutral /baselines endpoint' { + Set-V1TenantsSession + & $script:M { Resolve-InforcerBaselineId -BaselineId 'contoso baseline' } | Should -Be $script:B1 + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly -ParameterFilter { $Request.Uri -eq 'https://api.example.invalid/v1/baselines' } + } +} + +Describe 'Get-InforcerAlignmentDetails' { + It 'v1: the scores table carries TargetTenantMsTenantId and no client tenant id' { + Set-V1TenantsSession + $r = @(Get-InforcerAlignmentDetails) + $r.Count | Should -Be 2 + $r[0].PSObject.Properties.Name | Should -Be @('TargetTenantFriendlyName', 'TargetTenantMsTenantId', 'AlignmentScore', 'BaselineName', 'BaselineId', 'LastComparisonDateTime') + $r[0].TargetTenantMsTenantId | Should -Be $script:T2 + $r[0].AlignmentScore | Should -BeExactly '80,5' + $r[0].BaselineId | Should -Be $script:B1 + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly + } + + It 'beta: the scores table shape is unchanged' { + Set-V1TenantsSession -Beta + $r = @(Get-InforcerAlignmentDetails) + $r.Count | Should -Be 2 + $r[0].PSObject.Properties.Name | Should -Be @('TargetTenantFriendlyName', 'TargetTenantClientTenantId', 'AlignmentScore', 'BaselineName', 'BaselineId', 'LastComparisonDateTime') + $r[0].TargetTenantClientTenantId | Should -BeOfType [int] + $r[0].TargetTenantClientTenantId | Should -Be 2 + (Get-InforcerAlignmentDetails -OutputType JsonObject).TrimStart() | Should -BeLike '`[*' + } + + It '-TenantId of a baseline owner includes the tenants aligned to it, in both modes' { + Set-V1TenantsSession + @(Get-InforcerAlignmentDetails -TenantId $script:T1).TargetTenantMsTenantId | Sort-Object | Should -Be @($script:T1, $script:T2) + @(Get-InforcerAlignmentDetails -TenantId 'Fabrikam').TargetTenantMsTenantId | Should -Be @($script:T2) + Set-V1TenantsSession -Beta + @(Get-InforcerAlignmentDetails -TenantId 1).TargetTenantClientTenantId | Sort-Object | Should -Be @(1, 2) + @(Get-InforcerAlignmentDetails -TenantId 2).TargetTenantClientTenantId | Should -Be @(2) + } + + It 'v1: -Tag loads tenant records and matches the tag name' { + Set-V1TenantsSession + $r = @(Get-InforcerAlignmentDetails -Tag 'prod') + $r.TargetTenantMsTenantId | Should -Be @($script:T1) + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 2 -Exactly -ParameterFilter { $Request.Uri -match '/v1/tenants/[0-9a-f-]{36}$' } + @(Get-InforcerAlignmentDetails -Tag 'Test').TargetTenantMsTenantId | Should -Be @('00000000-0000-0000-0000-000000000002') + } + + It 'v1: -Format Raw -TenantId filters on msTenantId in both output types' { + Set-V1TenantsSession + $r = @(Get-InforcerAlignmentDetails -Format Raw -TenantId $script:T2) + $r.Count | Should -Be 1 + $r[0].tenantFriendlyName | Should -BeExactly 'Fabrikam' + $r[0].score | Should -Be 80.5 + $r[0].PSObject.TypeNames[0] | Should -Be 'InforcerCommunity.AlignmentScoreRaw' + $parsed = @(Get-InforcerAlignmentDetails -Format Raw -TenantId 'Contoso' -OutputType JsonObject | ConvertFrom-Json -Depth 100) + $parsed.msTenantId | Should -Be @($script:T1) + } + + It 'v1: -BaselineId without -TenantId queries the first member by msTenantId' { + Set-V1TenantsSession + $r = @(Get-InforcerAlignmentDetails -BaselineId 'Contoso Baseline' 6>$null) + ($r | Sort-Object PolicyName).AlignmentStatus | Should -Be @('Aligned', 'Unaccepted Deviation') + $r[0].PSObject.TypeNames[0] | Should -Be 'InforcerCommunity.AlignmentDetailPolicy' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 1 -Exactly -ParameterFilter { $Request.Uri -eq 'https://api.example.invalid/v1/tenants/00000000-0000-0000-0000-000000000002/alignmentDetails?baselineId=00000000-0000-0000-0000-0000000000b1' } + } + + It 'returns the same detail rows and raw metrics on v1 and beta' { + Set-V1TenantsSession + $vRows = @(Get-InforcerAlignmentDetails -TenantId 'Fabrikam' -BaselineId $script:B1 6>$null) | Sort-Object PolicyName | ConvertTo-Json -Depth 5 + $vRaw = Get-InforcerAlignmentDetails -TenantId $script:T2 -BaselineId $script:B1 -Format Raw -OutputType JsonObject | ConvertFrom-Json -Depth 100 + Set-V1TenantsSession -Beta + $bRows = @(Get-InforcerAlignmentDetails -TenantId 2 -BaselineId $script:B1 6>$null) | Sort-Object PolicyName | ConvertTo-Json -Depth 5 + $bRaw = Get-InforcerAlignmentDetails -TenantId 2 -BaselineId $script:B1 -Format Raw -OutputType JsonObject | ConvertFrom-Json -Depth 100 + $vRows | Should -BeExactly $bRows + ($vRaw.metrics | ConvertTo-Json -Compress) | Should -BeExactly ($bRaw.metrics | ConvertTo-Json -Compress) + $vRaw.alignment.matchedPolicies[0].policyName | Should -BeExactly 'Policy A' + @($vRaw.alignment.deviatedUnaccepted).Count | Should -Be @($bRaw.alignment.deviatedUnaccepted).Count + } + + It 'v1: a numeric -TenantId with -BaselineId is InvalidTenantId and calls no detail endpoint' { + Set-V1TenantsSession + $null = Get-InforcerAlignmentDetails -TenantId 139 -BaselineId $script:B1 -ErrorVariable e -ErrorAction SilentlyContinue 6>$null + $e[0].FullyQualifiedErrorId | Should -BeLike 'InvalidTenantId*' + Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 0 -Exactly -ParameterFilter { $Request.Uri -match 'alignmentDetails' } + } +} + +Describe 'Format.ps1xml views' { + It 'Tenant: beta shows ClientTenantId; v1 shows MsTenantId and hides rows it has no data for' { + Set-V1TenantsSession -Beta + $beta = @(Get-InforcerTenant)[0] | Format-List | Out-String + $beta | Should -Match 'ClientTenantId\s+: 1' + $beta | Should -Match 'licenses\s+: SPE_E5' + $beta | Should -Not -Match 'AuthenticationMode' + Set-V1TenantsSession + $light = @(Get-InforcerTenant)[0] | Format-List | Out-String + $light | Should -Not -Match 'ClientTenantId|licenses|IsBaseline' + $light | Should -Match "MsTenantId\s+: $($script:T1)" + $detail = Get-InforcerTenant -TenantId 'Contoso' | Format-List | Out-String + $detail | Should -Match 'IsBaseline\s+: True' + $detail | Should -Match 'AuthenticationMode\s+: Standard' + $detail | Should -Not -Match 'ClientTenantId' + } + + It 'Baseline: OwnerTenantId and member ids fall back to the Microsoft tenant id on v1' { + Set-V1TenantsSession -Beta + $beta = @(Get-InforcerBaseline)[0] | Format-List | Out-String + $beta | Should -Match 'OwnerTenantId\s+: 1' + $beta | Should -Match 'Fabrikam \(2\)' + Set-V1TenantsSession + $v1 = @(Get-InforcerBaseline)[0] | Format-List | Out-String + $v1 | Should -Match "OwnerTenantId\s+: $($script:T1)" + $v1 | Should -Match "Fabrikam \($($script:T2)\)" + ([regex]::Matches($v1, 'OwnerTenantId')).Count | Should -Be 1 + } + + It 'AlignmentScore: the TenantId row shows the client id on beta and the Microsoft id on v1' { + Set-V1TenantsSession -Beta + (@(Get-InforcerAlignmentDetails)[0] | Format-List | Out-String) | Should -Match 'TenantId\s+: 2' + Set-V1TenantsSession + $v1 = @(Get-InforcerAlignmentDetails)[0] | Format-List | Out-String + $v1 | Should -Match "TenantId\s+: $($script:T2)" + ([regex]::Matches($v1, '(?m)^TenantId')).Count | Should -Be 1 + } +} +} diff --git a/module/InforcerCommunity.Format.ps1xml b/module/InforcerCommunity.Format.ps1xml index 725b707..c10d3a4 100644 --- a/module/InforcerCommunity.Format.ps1xml +++ b/module/InforcerCommunity.Format.ps1xml @@ -12,14 +12,35 @@ - ClientTenantId + + + $null -ne $_.PSObject.Properties['clientTenantId'] + ClientTenantId + MsTenantId TenantFriendlyName TenantDnsName - licenses - SecureScore - IsBaseline - LastBackupTimestamp + + $null -ne $_.PSObject.Properties['licenses'] + licenses + + + $null -ne $_.PSObject.Properties['secureScore'] + SecureScore + + + $null -ne $_.PSObject.Properties['isBaseline'] + IsBaseline + + + $null -ne $_.PSObject.Properties['lastBackupTimestamp'] + LastBackupTimestamp + + + $null -ne $_.PSObject.Properties['authenticationMode'] + AuthenticationMode + @@ -39,8 +60,15 @@ BaselineName BaselineId BaselineTenantFriendlyName - BaselineClientTenantId - if ($_.members -and $_.members.Count -gt 0) { ($_.members | ForEach-Object { "$($_.TenantFriendlyName) ($($_.ClientTenantId))" }) -join ', ' } else { '(none)' } + + $null -ne $_.PSObject.Properties['baselineClientTenantId'] + BaselineClientTenantId + + + $null -eq $_.PSObject.Properties['baselineClientTenantId'] + BaselineMsTenantId + + if ($_.members -and $_.members.Count -gt 0) { ($_.members | ForEach-Object { "$($_.TenantFriendlyName) ($(if ($null -ne $_.PSObject.Properties['clientTenantId']) { $_.ClientTenantId } else { $_.MsTenantId }))" }) -join ', ' } else { '(none)' } AlignedThreshold SemiAlignedThreshold $_.mode @@ -129,7 +157,14 @@ TargetTenantFriendlyName - TargetTenantClientTenantId + + $null -ne $_.PSObject.Properties['TargetTenantClientTenantId'] + TargetTenantClientTenantId + + + $null -ne $_.PSObject.Properties['TargetTenantMsTenantId'] + TargetTenantMsTenantId + AlignmentScore BaselineName LastComparisonDateTime diff --git a/module/Private/Resolve-InforcerBaselineId.ps1 b/module/Private/Resolve-InforcerBaselineId.ps1 index cc8cb04..c089249 100644 --- a/module/Private/Resolve-InforcerBaselineId.ps1 +++ b/module/Private/Resolve-InforcerBaselineId.ps1 @@ -9,7 +9,7 @@ function Resolve-InforcerBaselineId { .PARAMETER BaselineId Baseline GUID or friendly baseline name. .PARAMETER BaselineData - Optional. Pre-fetched array of baseline objects (e.g. from GET /beta/baselines). When provided + Optional. Pre-fetched array of baseline objects (e.g. from GET /baselines). When provided and BaselineId is a name, resolution is done from this data instead of calling the API. .OUTPUTS System.String - The resolved baseline GUID. @@ -36,7 +36,7 @@ function Resolve-InforcerBaselineId { Write-Verbose "Baseline name detected: '$baselineIdString'. Looking up baseline GUID..." $baselinesToUse = $BaselineData if ($null -eq $baselinesToUse -or $baselinesToUse.Count -eq 0) { - $baselinesToUse = @(Invoke-InforcerApiRequest -Endpoint '/beta/baselines' -Method GET -OutputType PowerShellObject) + $baselinesToUse = @(Invoke-InforcerApiRequest -Endpoint '/baselines' -Method GET -OutputType PowerShellObject -ObjectType Baseline) } $baselines = if ($baselinesToUse -is [array]) { $baselinesToUse } else { @($baselinesToUse) } diff --git a/module/Public/Get-InforcerAlignmentDetails.ps1 b/module/Public/Get-InforcerAlignmentDetails.ps1 index 4964a62..a9047ec 100644 --- a/module/Public/Get-InforcerAlignmentDetails.ps1 +++ b/module/Public/Get-InforcerAlignmentDetails.ps1 @@ -10,13 +10,19 @@ When -TenantId is also specified, queries a single tenant. When only -BaselineId is specified, queries the first member tenant (baseline policies are identical across members). -BaselineId accepts a GUID or a friendly baseline name (resolved via the baselines API). + + API-key login: table rows identify the tenant by TargetTenantClientTenantId. + Client-credentials login: numeric tenant ids do not exist; table rows carry TargetTenantMsTenantId + instead, and -Tag loads every tenant's record (one call per tenant) because only that holds tags. .PARAMETER Format Table (default) or Raw. .PARAMETER TenantId Optional. Filter to this tenant. When used with -BaselineId, queries only this tenant. + Accepts the Microsoft Tenant ID (GUID), the tenant name, or (API-key login only) the numeric + Client Tenant ID. .PARAMETER BaselineId Optional. Baseline GUID or friendly name. When provided, retrieves detailed alignment - data. Without -TenantId, loops through all member tenants of the baseline. + data. Without -TenantId, queries the first member tenant of the baseline. .PARAMETER Tag Optional. When Format is Table (without -BaselineId), filter to tenants with tag containing this value (case-insensitive). .PARAMETER OutputType @@ -28,14 +34,14 @@ .EXAMPLE Get-InforcerAlignmentDetails -TenantId 482 -Tag Production .EXAMPLE - Get-InforcerAlignmentDetails -TenantId 139 -BaselineId "Provision M365" - Retrieves detailed alignment data using the baseline friendly name. + Get-InforcerAlignmentDetails -TenantId 'Contoso' -BaselineId "Provision M365" + Retrieves detailed alignment data using the tenant name and the baseline friendly name (both logins). .EXAMPLE - Get-InforcerAlignmentDetails -TenantId 139 -BaselineId "91e0b0f7-69f1-453f-8d73-5a6f726b5b21" -Format Raw - Retrieves raw alignment detail response by baseline GUID. + Get-InforcerAlignmentDetails -TenantId '00000000-0000-0000-0000-000000000001' -BaselineId "91e0b0f7-69f1-453f-8d73-5a6f726b5b21" -Format Raw + Retrieves the raw alignment detail response by Microsoft Tenant ID and baseline GUID. .EXAMPLE Get-InforcerAlignmentDetails -BaselineId "Inforcer Blueprint Baseline - Tier 2 - Enhanced" - Retrieves alignment details for all member tenants of the baseline. + Retrieves alignment details for the first member tenant of the baseline. .OUTPUTS PSObject or String .LINK @@ -52,7 +58,7 @@ param( [string]$Format = 'Table', [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, [Parameter(Mandatory = $false)] @@ -71,6 +77,19 @@ if (-not (Test-InforcerSession)) { return } +$isV1 = $script:InforcerSession.ApiVersion -eq 'v1' + +# The id the current API keys tenants by: beta [int] clientTenantId, v1 [string] msTenantId. +function Get-TenantKey($Object, [string]$BetaProperty = 'clientTenantId') { + $name = if ($isV1) { 'msTenantId' } else { $BetaProperty } + $value = $Object.PSObject.Properties[$name].Value + if ($null -eq $value) { return $null } + if ($isV1) { return "$value" } + $n = 0 + if ([int]::TryParse($value.ToString(), [ref]$n)) { return $n } + $null +} + # --- Alignment Details mode: -BaselineId provided --- if (-not [string]::IsNullOrWhiteSpace($BaselineId)) { @@ -80,7 +99,7 @@ if (-not [string]::IsNullOrWhiteSpace($BaselineId)) { $guidTest = [guid]::Empty if (-not [guid]::TryParse($BaselineId.Trim(), [ref]$guidTest)) { # Name lookup — fetch baselines so we can reuse them for members - $allBaselines = @(Invoke-InforcerApiRequest -Endpoint '/beta/baselines' -Method GET -OutputType PowerShellObject) + $allBaselines = @(Invoke-InforcerApiRequest -Endpoint '/baselines' -Method GET -OutputType PowerShellObject -ObjectType Baseline) $baselineGuid = Resolve-InforcerBaselineId -BaselineId $BaselineId -BaselineData $allBaselines } else { $baselineGuid = $BaselineId.Trim() @@ -96,28 +115,24 @@ if (-not [string]::IsNullOrWhiteSpace($BaselineId)) { if ($null -ne $TenantId) { # Single tenant mode — resolve ID and friendly name try { - $clientTenantId = Resolve-InforcerTenantId -TenantId $TenantId + $resolvedTenantId = Resolve-InforcerTenantId -TenantId $TenantId -ErrorAction Stop } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument return } - $tenantFriendlyName = "Tenant $clientTenantId" - $tenantsForName = @(Invoke-InforcerApiRequest -Endpoint '/beta/tenants' -Method GET -OutputType PowerShellObject) - foreach ($t in $tenantsForName) { - if ($t -is [PSObject]) { - $cidProp = $t.PSObject.Properties['clientTenantId'] - if ($cidProp -and [int]$cidProp.Value -eq $clientTenantId) { - $fnProp = $t.PSObject.Properties['tenantFriendlyName'] - if ($fnProp -and $fnProp.Value) { $tenantFriendlyName = $fnProp.Value.ToString() } - break - } + $tenantFriendlyName = "Tenant $resolvedTenantId" + foreach ($t in @(Get-InforcerTenantList)) { + if ($t -is [PSObject] -and (Get-TenantKey $t) -eq $resolvedTenantId) { + $fnProp = $t.PSObject.Properties['tenantFriendlyName'] + if ($fnProp -and $fnProp.Value) { $tenantFriendlyName = $fnProp.Value.ToString() } + break } } - [void]$tenantsToQuery.Add(@{ Id = $clientTenantId; Name = $tenantFriendlyName }) + [void]$tenantsToQuery.Add(@{ Id = $resolvedTenantId; Name = $tenantFriendlyName }) } else { # All members mode — find baseline members if ($null -eq $allBaselines) { - $allBaselines = @(Invoke-InforcerApiRequest -Endpoint '/beta/baselines' -Method GET -OutputType PowerShellObject) + $allBaselines = @(Invoke-InforcerApiRequest -Endpoint '/baselines' -Method GET -OutputType PowerShellObject -ObjectType Baseline) } $baselineObj = $null foreach ($bl in $allBaselines) { @@ -140,9 +155,8 @@ if (-not [string]::IsNullOrWhiteSpace($BaselineId)) { # Pick first member only — baseline policies are the same regardless of member foreach ($member in @($membersProp.Value)) { if (-not ($member -is [PSObject])) { continue } - $mIdProp = $member.PSObject.Properties['clientTenantId'] - if (-not $mIdProp -or $null -eq $mIdProp.Value) { continue } - $mId = [int]$mIdProp.Value + $mId = Get-TenantKey $member + if ($null -eq $mId) { continue } $mNameProp = $member.PSObject.Properties['tenantFriendlyName'] $mName = if ($mNameProp -and $null -ne $mNameProp.Value) { $mNameProp.Value.ToString() } else { "Tenant $mId" } [void]$tenantsToQuery.Add(@{ Id = $mId; Name = $mName }) @@ -170,8 +184,8 @@ if (-not [string]::IsNullOrWhiteSpace($BaselineId)) { $tName = $tenantEntry.Name Write-Verbose "Retrieving alignment details for tenant $tId ($tName), baseline $baselineGuid..." - $detailEndpoint = "/beta/tenants/$tId/alignmentDetails?customBaselineId=$baselineGuid" - $response = Invoke-InforcerApiRequest -Endpoint $detailEndpoint -Method GET -OutputType $OutputType -ErrorAction SilentlyContinue -ErrorVariable apiErr + $detailEndpoint = "/tenants/$tId/alignmentDetails?customBaselineId=$baselineGuid" + $response = Invoke-InforcerApiRequest -Endpoint $detailEndpoint -Method GET -OutputType $OutputType -ObjectType AlignmentDetails -ErrorAction SilentlyContinue -ErrorVariable apiErr if ($null -eq $response) { $errMsg = if ($apiErr -and $apiErr.Count -gt 0) { $apiErr[0].Exception.Message } else { $null } if ($errMsg -match 'permission|forbidden|access') { @@ -278,32 +292,36 @@ function FormatAlignmentScore($scoreVal) { if ($Format -eq 'Raw') { Write-Verbose 'Retrieving alignment scores (raw)...' - $response = Invoke-InforcerApiRequest -Endpoint '/beta/alignmentScores' -Method GET -OutputType $OutputType + $response = Invoke-InforcerApiRequest -Endpoint '/alignmentScores' -Method GET -OutputType $OutputType -ObjectType AlignmentScore if ($null -eq $response) { return } if ($null -ne $TenantId) { try { - $clientTenantId = Resolve-InforcerTenantId -TenantId $TenantId + $resolvedTenantId = Resolve-InforcerTenantId -TenantId $TenantId -ErrorAction Stop } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument return } - Write-Verbose "Filtering alignment scores to tenant ID $clientTenantId..." - $predicate = { - param($p) - $tidProp = $p.PSObject.Properties['tenantId'] - if (-not $tidProp) { $tidProp = $p.PSObject.Properties['clientTenantId'] } - if ($tidProp -and [int]$tidProp.Value -eq $clientTenantId) { return $true } - $summariesProp = $p.PSObject.Properties['alignmentSummaries'] - if ($summariesProp -and $summariesProp.Value -is [object[]]) { - foreach ($s in $summariesProp.Value) { - if ($s -is [PSObject]) { - $aidProp = $s.PSObject.Properties['alignedBaselineTenantId'] - if ($aidProp -and [int]$aidProp.Value -eq $clientTenantId) { return $true } + Write-Verbose "Filtering alignment scores to tenant ID $resolvedTenantId..." + $predicate = if ($isV1) { + { param($p) "$($p.PSObject.Properties['msTenantId'].Value)" -eq $resolvedTenantId } + } else { + { + param($p) + $tidProp = $p.PSObject.Properties['tenantId'] + if (-not $tidProp) { $tidProp = $p.PSObject.Properties['clientTenantId'] } + if ($tidProp -and [int]$tidProp.Value -eq $resolvedTenantId) { return $true } + $summariesProp = $p.PSObject.Properties['alignmentSummaries'] + if ($summariesProp -and $summariesProp.Value -is [object[]]) { + foreach ($s in $summariesProp.Value) { + if ($s -is [PSObject]) { + $aidProp = $s.PSObject.Properties['alignedBaselineTenantId'] + if ($aidProp -and [int]$aidProp.Value -eq $resolvedTenantId) { return $true } + } } } + $false } - $false } if ($OutputType -eq 'JsonObject') { Filter-InforcerResponse -InputObject $response -FilterScript $predicate -OutputType JsonObject @@ -333,57 +351,45 @@ if ($Format -eq 'Raw') { return } -# Format Table: use /beta/alignmentScores for fresh data, optionally join tenant names from /beta/tenants +# Format Table: alignment scores, joined with the tenant list only when filtering needs it Write-Verbose 'Retrieving alignment scores for table...' -$alignmentResponse = Invoke-InforcerApiRequest -Endpoint '/beta/alignmentScores' -Method GET -OutputType PowerShellObject +$alignmentResponse = Invoke-InforcerApiRequest -Endpoint '/alignmentScores' -Method GET -OutputType PowerShellObject -ObjectType AlignmentScore if ($null -eq $alignmentResponse) { return } $allAlignmentData = ConvertTo-InforcerArray $alignmentResponse -# Detect flat format before deciding whether we need /beta/tenants +$scoreTenantProperty = if ($isV1) { 'msTenantId' } else { 'tenantId' } $firstItem = $null if ($allAlignmentData.Count -gt 0) { $firstItem = $allAlignmentData[0] } -$flatFormat = ($firstItem -is [PSObject]) -and $firstItem.PSObject.Properties['tenantId'] -and $firstItem.PSObject.Properties['score'] -and -not $firstItem.PSObject.Properties['alignmentSummaries'] +$flatFormat = ($firstItem -is [PSObject]) -and $firstItem.PSObject.Properties[$scoreTenantProperty] -and $firstItem.PSObject.Properties['score'] -and -not $firstItem.PSObject.Properties['alignmentSummaries'] -# Only fetch tenants when needed: nested format, or -TenantId, or -Tag (for baseline-owner expansion or tag filtering) $needTenants = (-not $flatFormat) -or ($null -ne $TenantId) -or -not [string]::IsNullOrWhiteSpace($Tag) +# Tenant objects come from the shared tenant cache: read them, never modify them. $allTenants = @() -$tenantLookup = @{} if ($needTenants) { Write-Verbose 'Retrieving tenant information for alignment table...' - $tenantResponse = Invoke-InforcerApiRequest -Endpoint '/beta/tenants' -Method GET -OutputType PowerShellObject - if ($null -eq $tenantResponse) { return } - $allTenants = ConvertTo-InforcerArray $tenantResponse - foreach ($t in $allTenants) { - if ($t -is [PSObject]) { - $null = Add-InforcerPropertyAliases -InputObject $t -ObjectType Tenant - $idProp = $t.PSObject.Properties['clientTenantId'] - if ($null -ne $idProp -and $null -ne $idProp.Value) { - $id = [int]$idProp.Value - $tenantLookup[$id] = $t - } - } - } + $allTenants = @(if ($isV1 -and -not [string]::IsNullOrWhiteSpace($Tag)) { + # v1 tenant tags exist only on the per-tenant record. + Get-InforcerTenant -Detailed + } else { + Get-InforcerTenantList + }) + if ($allTenants.Count -eq 0) { return } } -# Filter tenants by TenantId and Tag (same as before) $tenants = @($allTenants | Where-Object { $_ -is [PSObject] }) if ($null -ne $TenantId) { try { - $clientTenantId = Resolve-InforcerTenantId -TenantId $TenantId -TenantData $allTenants + $resolvedTenantId = Resolve-InforcerTenantId -TenantId $TenantId -TenantData $allTenants -ErrorAction Stop } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument return } - Write-Verbose "Filtering to tenant ID: $clientTenantId" - $tenants = @($tenants | Where-Object { - $idProp = $_.PSObject.Properties['clientTenantId'] - $idProp -and [int]$idProp.Value -eq $clientTenantId - }) - # If no tenant with this ID exists in the system, return nothing. + Write-Verbose "Filtering to tenant ID: $resolvedTenantId" + $tenants = @($tenants | Where-Object { (Get-TenantKey $_) -eq $resolvedTenantId }) if ($tenants.Count -eq 0) { - Write-Verbose "No tenant found with ID $clientTenantId. Returning no results." + Write-Verbose "No tenant found with ID $resolvedTenantId. Returning no results." return } } @@ -394,7 +400,12 @@ if (-not [string]::IsNullOrWhiteSpace($Tag)) { $tagsProp = $_.PSObject.Properties['tags'] if (-not $tagsProp -or $null -eq $tagsProp.Value) { return $false } $val = $tagsProp.Value - if ($val -is [object[]]) { + if ($isV1) { + foreach ($x in @($val)) { + $text = if ($x -is [PSObject] -and $x.PSObject.Properties['name']) { $x.PSObject.Properties['name'].Value } else { $x } + if ($text -and $text.ToString().IndexOf($Tag, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true } + } + } elseif ($val -is [object[]]) { foreach ($x in $val) { if ($x -and $x.ToString().IndexOf($Tag, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true } } @@ -407,7 +418,7 @@ if (-not [string]::IsNullOrWhiteSpace($Tag)) { Write-Verbose "Building alignment table from alignment scores ($($allAlignmentData.Count) source(s))..." -# Helper: add to $targetIds any tenant that is aligned to a baseline owned by an ID in $baselineOwnerIds +# beta: add to $targetIds any tenant that is aligned to a baseline owned by an ID in $baselineOwnerIds function Add-ChildTenantIdsFromAlignments { param([System.Collections.Hashtable]$targetIds, [array]$allTenants, [array]$baselineOwnerIds) foreach ($t in $allTenants) { @@ -437,16 +448,24 @@ function Add-ChildTenantIdsFromAlignments { $tenantIds = @{} if ($null -ne $TenantId -or -not [string]::IsNullOrWhiteSpace($Tag)) { foreach ($t in $tenants) { - $idProp = $t.PSObject.Properties['clientTenantId'] - if ($idProp -and $null -ne $idProp.Value) { - $tid = 0 - if ([int]::TryParse($idProp.Value.ToString(), [ref]$tid)) { $tenantIds[$tid] = $true } - } + $tid = Get-TenantKey $t + if ($null -ne $tid) { $tenantIds[$tid] = $true } } # If the requested tenant is a baseline owner, also include tenants aligned TO it if ($null -ne $TenantId -and $tenantIds.Count -gt 0) { $baselineOwnerIds = @($tenantIds.Keys) - Add-ChildTenantIdsFromAlignments -targetIds $tenantIds -allTenants $allTenants -baselineOwnerIds $baselineOwnerIds + if ($isV1) { + # v1 tenants carry no alignmentSummaries in the list; baseline members are the aligned tenants. + foreach ($bl in @(Invoke-InforcerApiRequest -Endpoint '/baselines' -Method GET -OutputType PowerShellObject -ObjectType Baseline)) { + if ($bl -isnot [PSObject] -or "$($bl.PSObject.Properties['baselineMsTenantId'].Value)" -notin $baselineOwnerIds) { continue } + foreach ($m in @($bl.PSObject.Properties['members'].Value)) { + $mId = if ($m -is [PSObject]) { Get-TenantKey $m } + if ($null -ne $mId) { $tenantIds[$mId] = $true } + } + } + } else { + Add-ChildTenantIdsFromAlignments -targetIds $tenantIds -allTenants $allTenants -baselineOwnerIds $baselineOwnerIds + } } } @@ -456,29 +475,41 @@ $alignmentTable = [System.Collections.ArrayList]::new() if ($flatFormat) { foreach ($item in $allAlignmentData) { if (-not ($item -is [PSObject])) { continue } - $tidProp = $item.PSObject.Properties['tenantId'] - if (-not $tidProp) { $tidProp = $item.PSObject.Properties['clientTenantId'] } - $targetTenantClientTenantId = 0 - if ($tidProp -and $null -ne $tidProp.Value) { - if (-not [int]::TryParse($tidProp.Value.ToString(), [ref]$targetTenantClientTenantId)) { continue } - } - if ($tenantIds.Count -gt 0 -and -not $tenantIds.ContainsKey($targetTenantClientTenantId)) { continue } - $scoreVal = $item.PSObject.Properties['score'].Value $alignmentScoreFormatted = FormatAlignmentScore $scoreVal - $row = [PSCustomObject]@{ - TargetTenantFriendlyName = ($item.PSObject.Properties['tenantFriendlyName'].Value -as [string]) - TargetTenantClientTenantId = $targetTenantClientTenantId - AlignmentScore = $alignmentScoreFormatted - BaselineName = $item.PSObject.Properties['baselineGroupName'].Value - BaselineId = $item.PSObject.Properties['baselineGroupId'].Value - LastComparisonDateTime = $item.PSObject.Properties['lastComparisonDateTime'].Value + if ($isV1) { + $targetTenantMsTenantId = Get-TenantKey $item + if ($tenantIds.Count -gt 0 -and ($null -eq $targetTenantMsTenantId -or -not $tenantIds.ContainsKey($targetTenantMsTenantId))) { continue } + $row = [PSCustomObject]@{ + TargetTenantFriendlyName = ($item.PSObject.Properties['tenantFriendlyName'].Value -as [string]) + TargetTenantMsTenantId = $targetTenantMsTenantId + AlignmentScore = $alignmentScoreFormatted + BaselineName = $item.PSObject.Properties['baselineGroupName'].Value + BaselineId = $item.PSObject.Properties['baselineGroupId'].Value + LastComparisonDateTime = $item.PSObject.Properties['lastComparisonDateTime'].Value + } + } else { + $tidProp = $item.PSObject.Properties['tenantId'] + if (-not $tidProp) { $tidProp = $item.PSObject.Properties['clientTenantId'] } + $targetTenantClientTenantId = 0 + if ($tidProp -and $null -ne $tidProp.Value) { + if (-not [int]::TryParse($tidProp.Value.ToString(), [ref]$targetTenantClientTenantId)) { continue } + } + if ($tenantIds.Count -gt 0 -and -not $tenantIds.ContainsKey($targetTenantClientTenantId)) { continue } + $row = [PSCustomObject]@{ + TargetTenantFriendlyName = ($item.PSObject.Properties['tenantFriendlyName'].Value -as [string]) + TargetTenantClientTenantId = $targetTenantClientTenantId + AlignmentScore = $alignmentScoreFormatted + BaselineName = $item.PSObject.Properties['baselineGroupName'].Value + BaselineId = $item.PSObject.Properties['baselineGroupId'].Value + LastComparisonDateTime = $item.PSObject.Properties['lastComparisonDateTime'].Value + } } $row.PSObject.TypeNames.Insert(0, 'InforcerCommunity.AlignmentScore') [void]$alignmentTable.Add($row) } -} else { +} elseif (-not $isV1) { foreach ($tenant in $allAlignmentData) { if (-not ($tenant -is [PSObject])) { continue } $cidProp = $tenant.PSObject.Properties['clientTenantId'] diff --git a/module/Public/Get-InforcerBaseline.ps1 b/module/Public/Get-InforcerBaseline.ps1 index 7d2c05a..85dcf98 100644 --- a/module/Public/Get-InforcerBaseline.ps1 +++ b/module/Public/Get-InforcerBaseline.ps1 @@ -5,16 +5,23 @@ Required API scope(s): Baselines.Read .DESCRIPTION Retrieves baseline groups and members. Optionally filter by -TenantId (owner or member). + API-key login: the owner is identified by BaselineClientTenantId and members by ClientTenantId. + Client-credentials login: those numeric ids do not exist; the owner is BaselineMsTenantId and + members carry MsTenantId. .PARAMETER Format Raw (default). .PARAMETER TenantId - Optional. Filter baselines where this tenant is owner or member. + Optional. Filter baselines where this tenant is owner or member. Accepts the Microsoft Tenant ID + (GUID), the tenant name, or (API-key login only) the numeric Client Tenant ID. .PARAMETER OutputType PowerShellObject (default) or JsonObject. .EXAMPLE Get-InforcerBaseline .EXAMPLE Get-InforcerBaseline -TenantId 482 +.EXAMPLE + Get-InforcerBaseline -TenantId '00000000-0000-0000-0000-000000000001' + Baselines owned by, or including, the tenant with this Microsoft Tenant ID (both logins). .EXAMPLE Get-InforcerBaseline -OutputType JsonObject Returns JSON string with full depth (100). @@ -34,7 +41,7 @@ param( [string]$Format = 'Raw', [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, [Parameter(Mandatory = $false)] @@ -49,32 +56,43 @@ if (-not (Test-InforcerSession)) { Write-Verbose 'Retrieving baseline information...' -$response = Invoke-InforcerApiRequest -Endpoint '/beta/baselines' -Method GET -OutputType $OutputType +$response = Invoke-InforcerApiRequest -Endpoint '/baselines' -Method GET -OutputType $OutputType -ObjectType Baseline if ($null -eq $response) { return } $filterPredicate = $null if ($null -ne $TenantId) { try { - $clientTenantId = Resolve-InforcerTenantId -TenantId $TenantId + $resolvedTenantId = Resolve-InforcerTenantId -TenantId $TenantId -ErrorAction Stop } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument return } - Write-Verbose "Filtering baselines to tenant ID $clientTenantId..." - $filterPredicate = { - param($p) - $ownerProp = $p.PSObject.Properties['baselineClientTenantId'] - if ($ownerProp -and [int]$ownerProp.Value -eq $clientTenantId) { return $true } - $membersProp = $p.PSObject.Properties['members'] - if ($membersProp -and $membersProp.Value -is [object[]]) { - foreach ($m in $membersProp.Value) { - if ($m -is [PSObject]) { - $cid = $m.PSObject.Properties['clientTenantId'].Value - if ($null -ne $cid -and [int]$cid -eq $clientTenantId) { return $true } + Write-Verbose "Filtering baselines to tenant ID $resolvedTenantId..." + if ($resolvedTenantId -is [string]) { + $filterPredicate = { + param($p) + if ("$($p.PSObject.Properties['baselineMsTenantId'].Value)" -eq $resolvedTenantId) { return $true } + foreach ($m in @($p.PSObject.Properties['members'].Value)) { + if ($m -is [PSObject] -and "$($m.PSObject.Properties['msTenantId'].Value)" -eq $resolvedTenantId) { return $true } + } + $false + } + } else { + $filterPredicate = { + param($p) + $ownerProp = $p.PSObject.Properties['baselineClientTenantId'] + if ($ownerProp -and [int]$ownerProp.Value -eq $resolvedTenantId) { return $true } + $membersProp = $p.PSObject.Properties['members'] + if ($membersProp -and $membersProp.Value -is [object[]]) { + foreach ($m in $membersProp.Value) { + if ($m -is [PSObject]) { + $cid = $m.PSObject.Properties['clientTenantId'].Value + if ($null -ne $cid -and [int]$cid -eq $resolvedTenantId) { return $true } + } } } + $false } - $false } } diff --git a/module/Public/Get-InforcerTenant.ps1 b/module/Public/Get-InforcerTenant.ps1 index 27ee93d..5648490 100644 --- a/module/Public/Get-InforcerTenant.ps1 +++ b/module/Public/Get-InforcerTenant.ps1 @@ -5,19 +5,36 @@ Required API scope(s): Tenants.Read .DESCRIPTION Lists tenants. Optionally filter by -TenantId (numeric ID, Microsoft Tenant ID GUID, or tenant name). - Output includes PascalCase aliases (e.g. ClientTenantId, TenantFriendlyName). When the API - returns licenses as an array, it is converted to a comma-separated string in the licenses property. - PolicyDiff shows policy change information when available. + When the API returns licenses as an array, it is converted to a comma-separated string in the + licenses property. PolicyDiff shows policy change information when available. + + API-key login: the list already holds every tenant field (ClientTenantId, licenses, SecureScore, + IsBaseline, tags, alignmentSummaries, ...), so -Detailed changes nothing. + Client-credentials login: the list holds only TenantFriendlyName, TenantDnsName and MsTenantId. + -Detailed, or -TenantId, loads each tenant's full record (licenses, SecureScore, IsBaseline, tags, + alignmentSummaries, LastBackupTimestamp, AuthenticationMode) with one extra call per tenant. + ClientTenantId does not exist with client credentials; use MsTenantId or the tenant name. .PARAMETER Format Output format. Raw = raw API response (default). .PARAMETER TenantId - Optional. Filter to this tenant (numeric ID, GUID, or tenant name). + Optional. Return this tenant only, with its full record. Accepts the Microsoft Tenant ID (GUID), + the tenant name or DNS name, or (API-key login only) the numeric Client Tenant ID. +.PARAMETER Detailed + Load the full record of every tenant. Client-credentials login only; with an API key the list is + already complete and the switch is ignored, so scripts work with both logins. .PARAMETER OutputType PowerShellObject (default) or JsonObject. JSON output uses Depth 100. .EXAMPLE Get-InforcerTenant .EXAMPLE Get-InforcerTenant -TenantId 482 + API-key login: returns the tenant with Client Tenant ID 482. +.EXAMPLE + Get-InforcerTenant -TenantId 'Contoso' + Returns the tenant named Contoso with its full record (works with both logins). +.EXAMPLE + Get-InforcerTenant -Detailed | Where-Object IsBaseline + Lists baseline tenants. With client credentials -Detailed is needed for IsBaseline. .EXAMPLE Get-InforcerTenant -Format Raw -OutputType JsonObject .OUTPUTS @@ -36,14 +53,21 @@ param( [string]$Format = 'Raw', [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] - [Alias('ClientTenantId')] + [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, + [Parameter(Mandatory = $false)] + [switch]$Detailed, + [Parameter(Mandatory = $false)] [ValidateSet('PowerShellObject', 'JsonObject')] [string]$OutputType = 'PowerShellObject' ) +begin { + $listRefreshed = $false +} + process { if (-not (Test-InforcerSession)) { Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' -ErrorId 'NotConnected' -Category ConnectionError @@ -51,69 +75,83 @@ process { } Write-Verbose 'Retrieving tenant information...' + $isV1 = $script:InforcerSession.ApiVersion -eq 'v1' - # Fetch tenant list once — reuse for both name/GUID resolution and output - $tenantData = @(Invoke-InforcerApiRequest -Endpoint '/beta/tenants' -Method GET -OutputType PowerShellObject) - if ($null -eq $tenantData -or $tenantData.Count -eq 0) { return } + # Get-InforcerTenantList objects are a shared cache: filter and copy them, never modify them. + $tenantData = @(Get-InforcerTenantList -Refresh:(-not $listRefreshed)) + $listRefreshed = $true + if ($tenantData.Count -eq 0) { return } - $singleTenantId = $null if ($null -ne $TenantId) { try { - $singleTenantId = Resolve-InforcerTenantId -TenantId $TenantId -TenantData $tenantData + $singleTenantId = Resolve-InforcerTenantId -TenantId $TenantId -TenantData $tenantData -ErrorAction Stop } catch { Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument return } - } - - if ($OutputType -eq 'JsonObject') { - # Filter PSObjects first, then convert to JSON once — avoids unnecessary serialize/deserialize round-trip - $jsonData = $tenantData - if ($null -ne $singleTenantId) { - $jsonData = @($tenantData | Where-Object { - $id = $_.clientTenantId - if ($null -eq $id) { $id = $_.ClientTenantId } - $null -ne $id -and [int]$id -eq $singleTenantId - }) - if ($jsonData.Count -eq 0) { - Write-Error -Message "Tenant or resource not found." -ErrorId 'TenantNotFound' -Category ObjectNotFound - return + $tenantData = @(foreach ($t in $tenantData) { + if ($isV1) { + if ("$($t.PSObject.Properties['msTenantId'].Value)" -eq $singleTenantId) { $t } + } else { + $id = $t.PSObject.Properties['clientTenantId'].Value + if ($null -ne $id -and [int]$id -eq $singleTenantId) { $t } + } + }) + if ($tenantData.Count -eq 0) { + Write-Error -Message "Tenant or resource not found." -ErrorId 'TenantNotFound' -Category ObjectNotFound + return + } + if ($isV1) { + $detail = Invoke-InforcerApiRequest -Endpoint "/tenants/$singleTenantId" -Method GET -ObjectType Tenant + if ($null -eq $detail) { return } + $tenantData = @($detail) + } + } elseif ($isV1 -and $Detailed) { + $seenIds = @{} + $unique = @(foreach ($t in $tenantData) { + $id = $t.PSObject.Properties['msTenantId'].Value + if ($null -eq $id -or $seenIds.ContainsKey("$id")) { continue } + $seenIds["$id"] = $true + $t + }) + $details = [System.Collections.Generic.List[object]]::new($unique.Count) + for ($i = 0; $i -lt $unique.Count; $i++) { + $t = $unique[$i] + $name = $t.PSObject.Properties['tenantFriendlyName'].Value + Write-Progress -Activity 'Loading tenant details' -Status "$($i + 1) of $($unique.Count): $name" -PercentComplete ([int](100 * $i / $unique.Count)) + $detailErr = $null + $detail = Invoke-InforcerApiRequest -Endpoint "/tenants/$($t.msTenantId)" -Method GET -ObjectType Tenant -ErrorAction SilentlyContinue -ErrorVariable detailErr + if ($null -eq $detail) { + $reason = if ($detailErr) { $detailErr[0].Exception.Message } else { 'empty response' } + Write-Warning "Could not load details for tenant '$name'; returning its summary. $reason" + $detail = $t } + $details.Add($detail) } - return ($jsonData | ConvertTo-Json -Depth 100) + Write-Progress -Activity 'Loading tenant details' -Completed + $tenantData = $details.ToArray() } - # Force to array (API can return single object or array) - $all = @($tenantData) - foreach ($item in $all) { - if ($item -is [PSObject]) { - $null = Add-InforcerPropertyAliases -InputObject $item -ObjectType Tenant - $item.PSObject.TypeNames.Insert(0, 'InforcerCommunity.Tenant') - } + if ($OutputType -eq 'JsonObject') { + return ($tenantData | ConvertTo-Json -Depth 100) } - # Dedupe by ClientTenantId (aliases already applied above) + + $idProperty = if ($isV1) { 'msTenantId' } else { 'clientTenantId' } $seen = @{} - $result = foreach ($item in $all) { - $id = $item.ClientTenantId - if ($null -eq $id) { $id = $item.clientTenantId } - if ($null -eq $id) { $item; continue } - $idInt = [int]$id - if ($seen.ContainsKey($idInt)) { continue } - $seen[$idInt] = $true - $item - } - # Filter to one tenant when -TenantId passed - if ($null -ne $singleTenantId) { - $result = @($result) | Where-Object { - $id = $_.ClientTenantId - if ($null -eq $id) { $id = $_.clientTenantId } - $null -ne $id -and [int]$id -eq $singleTenantId - } - if (-not $result) { - Write-Error -Message "Tenant or resource not found." -ErrorId 'TenantNotFound' -Category ObjectNotFound - return + foreach ($item in $tenantData) { + if ($item -isnot [PSObject]) { $item; continue } + $id = $item.PSObject.Properties[$idProperty].Value + if ($null -ne $id) { + $key = if ($isV1) { "$id" } else { [int]$id } + if ($seen.ContainsKey($key)) { continue } + $seen[$key] = $true } + $copy = [ordered]@{} + foreach ($p in $item.PSObject.Properties) { $copy[$p.Name] = $p.Value } + $out = [PSCustomObject]$copy + $null = Add-InforcerPropertyAliases -InputObject $out -ObjectType Tenant + $out.PSObject.TypeNames.Insert(0, 'InforcerCommunity.Tenant') + $out } - $result } } From fda6479e71661b42bcea965aefce21694d4cb757 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:22:23 +0100 Subject: [PATCH 07/27] test: assert the InvalidTenantId record among the caller's errors The caught resolver error also lands in -ErrorVariable, as it already did for unknown tenant names, so the cmdlet's own record is not always first. Co-Authored-By: Claude Opus 5.5 --- Tests/V1.Tenants.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/V1.Tenants.Tests.ps1 b/Tests/V1.Tenants.Tests.ps1 index ba7b52d..39f2494 100644 --- a/Tests/V1.Tenants.Tests.ps1 +++ b/Tests/V1.Tenants.Tests.ps1 @@ -130,7 +130,7 @@ Describe 'Get-InforcerTenant' { $e1[0].FullyQualifiedErrorId | Should -BeLike 'TenantNotFound*' Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 0 -Exactly -ParameterFilter { $Request.Uri -match '/v1/tenants/[0-9a-f-]{36}$' } $null = Get-InforcerTenant -TenantId 482 -ErrorVariable e2 -ErrorAction SilentlyContinue - $e2[0].FullyQualifiedErrorId | Should -BeLike 'InvalidTenantId*' + @($e2 | Where-Object { $_.FullyQualifiedErrorId -like 'InvalidTenantId*' }).Count | Should -Be 1 $e2[0].Exception.Message | Should -Match 'API key' } @@ -325,7 +325,7 @@ Describe 'Get-InforcerAlignmentDetails' { It 'v1: a numeric -TenantId with -BaselineId is InvalidTenantId and calls no detail endpoint' { Set-V1TenantsSession $null = Get-InforcerAlignmentDetails -TenantId 139 -BaselineId $script:B1 -ErrorVariable e -ErrorAction SilentlyContinue 6>$null - $e[0].FullyQualifiedErrorId | Should -BeLike 'InvalidTenantId*' + @($e | Where-Object { $_.FullyQualifiedErrorId -like 'InvalidTenantId*' }).Count | Should -Be 1 Should -Invoke -ModuleName InforcerCommunity Invoke-InforcerHttpRequest -Times 0 -Exactly -ParameterFilter { $Request.Uri -match 'alignmentDetails' } } } From 9e49496afc716756d751e1be19388eb66995a417 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:57:27 +0100 Subject: [PATCH 08/27] fix: Connect-Inforcer -PassThru returns only the session It emitted the session and then the status object, so the documented $s = Connect-Inforcer -PassThru; Compare-InforcerEnvironments -SourceSession $s failed with 'Cannot convert System.Object[] to Hashtable'. Co-Authored-By: Claude Opus 5.5 --- module/Public/Connect-Inforcer.ps1 | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/module/Public/Connect-Inforcer.ps1 b/module/Public/Connect-Inforcer.ps1 index 332bd26..c253c0c 100644 --- a/module/Public/Connect-Inforcer.ps1 +++ b/module/Public/Connect-Inforcer.ps1 @@ -379,10 +379,6 @@ if (-not $skipPrime) { try { Write-Verbose "Skipping Reports catalog prime: $(Protect-InforcerApiKeyInText -Text $_.Exception.Message -Session $newSession)" } } # end of if (-not $skipPrime) wrapper -if ($PassThru) { - # An independent copy; secrets and tokens in it are SecureStrings only. - Write-Output $script:InforcerSession.Clone() -} # Also connect to Microsoft Graph if requested if ($FetchGraphData) { @@ -399,5 +395,6 @@ $out = [PSCustomObject]@{ BaseUrl = $baseUrlValue ConnectedAt = $script:InforcerSession.ConnectedAt } +if ($PassThru) { return $script:InforcerSession.Clone() } $out } From 1fa0e02d8768a692e2defea7476fe65e21d89638 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:57:27 +0100 Subject: [PATCH 09/27] feat: support v1 in Export-InforcerTenantDocumentation and Compare-InforcerEnvironments - Documentation data loads the detailed tenant on v1 and never falls back to another tenant when the record is missing - Baseline filtering on v1 joins on policy ids (v1 ids are Microsoft 365 ids) - Compare resolves each baseline owner in its own session, so mixed API-key and client-credentials sessions work - On v1 the HTML, Markdown, Excel and comparison reports carry a note when the API returned no Intune policies - Export -TenantId accepts the MsTenantId alias Co-Authored-By: Claude Opus 5.5 --- Tests/V1.Compare.Tests.ps1 | 243 ++++++++++++++++++ .../ConvertTo-InforcerComparisonHtml.ps1 | 4 + module/Private/ConvertTo-InforcerDocModel.ps1 | 16 +- module/Private/ConvertTo-InforcerHtml.ps1 | 4 + module/Private/ConvertTo-InforcerMarkdown.ps1 | 5 + module/Private/Export-InforcerDocExcel.ps1 | 5 + module/Private/Get-InforcerDocData.ps1 | 44 ++-- .../Select-InforcerBaselinePolicies.ps1 | 20 +- .../Public/Compare-InforcerEnvironments.ps1 | 88 ++++--- .../Export-InforcerTenantDocumentation.ps1 | 14 +- 10 files changed, 364 insertions(+), 79 deletions(-) create mode 100644 Tests/V1.Compare.Tests.ps1 diff --git a/Tests/V1.Compare.Tests.ps1 b/Tests/V1.Compare.Tests.ps1 new file mode 100644 index 0000000..ac35469 --- /dev/null +++ b/Tests/V1.Compare.Tests.ps1 @@ -0,0 +1,243 @@ +# V1.Compare.Tests.ps1 +# Export-InforcerTenantDocumentation, Compare-InforcerEnvironments and their private pipeline +# (Get-InforcerDocData, Select-InforcerBaselinePolicies, ConvertTo-InforcerDocModel) on both logins +# and with mixed sessions. Invoke-InforcerHttpRequest is mocked, so the real request, unwrap and v1 +# mapping run. Fixtures are anonymised (zero GUIDs, example names); no network calls. + +BeforeAll { + Remove-Module -Name 'InforcerCommunity' -ErrorAction SilentlyContinue + Import-Module (Join-Path $PSScriptRoot '../module/InforcerCommunity.psd1') -Force + + $script:T1 = '00000000-0000-0000-0000-000000000001' + $script:T2 = '00000000-0000-0000-0000-000000000002' + $script:B1 = '00000000-0000-0000-0000-0000000000b1' + + function global:Reset-V1CompareRoutes { + $global:V1CompareCalls = [System.Collections.Generic.List[string]]::new() + $global:V1CompareRoutes = [ordered]@{ + # v1 (client credentials) + '/v1/tenants$' = '{"tenants":[{"msTenantId":"00000000-0000-0000-0000-000000000001","name":"Contoso","dnsName":"contoso.onmicrosoft.com"},{"msTenantId":"00000000-0000-0000-0000-000000000002","name":"Fabrikam","dnsName":"fabrikam.onmicrosoft.com"}]}' + '/v1/tenants/00000000-0000-0000-0000-000000000001$' = '{"msTenantId":"00000000-0000-0000-0000-000000000001","name":"Contoso","dnsName":"contoso.onmicrosoft.com","secureScore":50,"isBaseline":true,"tags":[],"alignmentSummaries":[],"licenses":[],"authenticationMode":"Standard"}' + '/v1/tenants/00000000-0000-0000-0000-000000000002$' = '{"msTenantId":"00000000-0000-0000-0000-000000000002","name":"Fabrikam","dnsName":"fabrikam.onmicrosoft.com","secureScore":40,"isBaseline":false,"tags":[],"alignmentSummaries":[],"licenses":[],"authenticationMode":"Standard"}' + '/v1/baselines$' = '{"baselines":[{"id":"00000000-0000-0000-0000-0000000000b1","name":"Contoso Baseline","description":"","baselineTenant":{"msTenantId":"00000000-0000-0000-0000-000000000001","name":"Contoso","dnsName":"contoso.onmicrosoft.com"},"mode":"Monitor","isShared":false,"isSharedCopy":false,"members":[{"msTenantId":"00000000-0000-0000-0000-000000000002","name":"Fabrikam","dnsName":"fabrikam.onmicrosoft.com"}],"items":[]}]}' + '/v1/tenants/[0-9a-f-]{36}/policies$' = '{"policies":[{"id":"00000000-0000-0000-0000-0000000000d1","policyTypeId":30,"policyTypeName":"Antivirus Exclusions","name":null,"displayName":"Contoso AV exclusions","friendlyName":null,"description":"","readOnly":false,"product":"Defender","primaryGroup":"Endpoint security","secondaryGroup":"Antivirus","platform":"Windows","tags":[],"policyData":{"id":"00000000-0000-0000-0000-0000000000d1","displayName":"Contoso AV exclusions","excludedExtensions":"tmp"}},{"id":"00000000-0000-0000-0000-0000000000e1","policyTypeId":1,"policyTypeName":"Conditional Access","name":null,"displayName":"Block legacy auth (tenant copy)","friendlyName":null,"description":"","readOnly":false,"product":"Entra","primaryGroup":"Conditional Access","secondaryGroup":null,"platform":null,"tags":[],"policyData":{"id":"00000000-0000-0000-0000-0000000000e1","displayName":"Block legacy auth (tenant copy)","state":"enabled"}},{"id":"00000000-0000-0000-0000-0000000000e2","policyTypeId":1,"policyTypeName":"Conditional Access","name":null,"displayName":"Require MFA","friendlyName":null,"description":"","readOnly":false,"product":"Entra","primaryGroup":"Conditional Access","secondaryGroup":null,"platform":null,"tags":[],"policyData":{"id":"00000000-0000-0000-0000-0000000000e2","displayName":"Require MFA","state":"enabled"}}],"failedPolicyTypes":{}}' + '/v1/tenants/[0-9a-f-]{36}/alignmentDetails\?baselineId=' = '{"completedAt":"2026-01-02T00:00:00Z","baseline":{"id":"00000000-0000-0000-0000-0000000000b1","name":"Contoso Baseline"},"baselineDataTimestamp":"2026-01-01T00:00:00Z","tenantDataTimestamp":"2026-01-01T00:00:00Z","alignmentScore":50,"metrics":{"totalPolicies":2,"matched":1,"matchedWithAcceptedDeviations":0,"deviated":0,"missingFromTenant":1,"additionalInTenant":0},"policies":{"matched":[{"id":"00000000-0000-0000-0000-0000000000f1","policyTypeId":1,"policyTypeName":"Conditional Access","displayName":"Block legacy auth","friendlyName":null,"description":"","readOnly":false,"product":"Entra","primaryGroup":"Conditional Access","secondaryGroup":null,"platform":null,"tags":[],"tenantPolicies":[{"id":"00000000-0000-0000-0000-0000000000e1","policyDataDiff":[],"policyAssignmentsDiff":[],"tags":[],"acceptedDeviations":[]}],"variables":[],"acceptedDeviations":[],"similarPolicies":[]}],"matchedWithAcceptedDeviations":[],"deviated":[],"missingFromTenant":[{"id":"00000000-0000-0000-0000-0000000000f2","policyTypeId":1,"policyTypeName":"Conditional Access","displayName":"Block risky sign-ins","friendlyName":null,"description":"","readOnly":false,"product":"Entra","primaryGroup":"Conditional Access","secondaryGroup":null,"platform":null,"tags":[],"tenantPolicies":[],"variables":[],"acceptedDeviations":[],"similarPolicies":[]}],"additionalInTenant":[]}}' + # beta (API key) + '/beta/tenants$' = '{"success":true,"data":[{"clientTenantId":1,"msTenantId":"00000000-0000-0000-0000-000000000001","tenantFriendlyName":"Contoso","tenantDnsName":"contoso.onmicrosoft.com","licenses":[],"secureScore":50,"isBaseline":true,"tags":[],"alignmentSummaries":[]},{"clientTenantId":2,"msTenantId":"00000000-0000-0000-0000-000000000002","tenantFriendlyName":"Fabrikam","tenantDnsName":"fabrikam.onmicrosoft.com","licenses":[],"secureScore":40,"isBaseline":false,"tags":[],"alignmentSummaries":[]}]}' + '/beta/baselines$' = '{"success":true,"data":[{"id":"00000000-0000-0000-0000-0000000000b1","name":"Contoso Baseline","baselineClientTenantId":1,"baselineMsTenantId":"00000000-0000-0000-0000-000000000001","baselineTenantFriendlyName":"Contoso","baselineTenantDnsName":"contoso.onmicrosoft.com","mode":"Monitor","isShared":false,"members":[{"clientTenantId":2,"msTenantId":"00000000-0000-0000-0000-000000000002","tenantFriendlyName":"Fabrikam","tenantDnsName":"fabrikam.onmicrosoft.com"}],"items":[]}]}' + '/beta/tenants/\d+/policies$' = '{"success":true,"data":[{"id":"1001","policyTypeId":2,"name":null,"displayName":"Windows restrictions","friendlyName":null,"description":"","readOnly":false,"product":"Intune","primaryGroup":"Devices","secondaryGroup":"Windows","platform":"Windows","policyCategoryId":7,"tags":[],"policyData":{"id":"00000000-0000-0000-0000-0000000000a1","displayName":"Windows restrictions","@odata.type":"#microsoft.graph.windows10GeneralConfiguration","passwordRequired":true}},{"id":"1002","policyTypeId":1,"name":null,"displayName":"Block legacy auth","friendlyName":null,"description":"","readOnly":false,"product":"Entra","primaryGroup":"Conditional Access","secondaryGroup":null,"platform":null,"policyCategoryId":3,"tags":[],"policyData":{"id":"00000000-0000-0000-0000-0000000000a2","displayName":"Block legacy auth","state":"enabled"}},{"id":"1003","policyTypeId":1,"name":null,"displayName":"Require MFA","friendlyName":null,"description":"","readOnly":false,"product":"Entra","primaryGroup":"Conditional Access","secondaryGroup":null,"platform":null,"policyCategoryId":3,"tags":[],"policyData":{"id":"00000000-0000-0000-0000-0000000000a3","displayName":"Require MFA","state":"enabled"}}]}' + '/beta/tenants/\d+/alignmentDetails\?customBaselineId=' = '{"success":true,"data":{"metrics":{"totalPolicies":1,"matchedPolicies":1},"completedAt":"2026-01-02T00:00:00Z","alignment":{"alignmentScore":100,"matchedPolicies":[{"policyName":"Block legacy auth","policyGuid":"00000000-0000-0000-0000-0000000000f1","product":"Entra","matchingSubjectPolicies":[{"subjectPolicyGuid":null,"policyDataDiff":[],"policyAssignmentsDiff":[]}]}],"matchedWithAcceptedDeviations":[],"deviatedUnaccepted":[],"missingFromSubjectUnaccepted":[],"additionalInSubjectUnaccepted":[]}}}' + } + } + + function global:Get-V1CompareResponse([string]$Uri) { + $global:V1CompareCalls.Add($Uri) + foreach ($pattern in $global:V1CompareRoutes.Keys) { + if ($Uri -notmatch $pattern) { continue } + $route = $global:V1CompareRoutes[$pattern] + $status = 200 + if ($route -is [hashtable]) { $status = $route.Status; $route = $route.Body } + return [pscustomobject]@{ StatusCode = $status; Body = ($route | ConvertFrom-Json -Depth 100); Headers = @{} } + } + [pscustomobject]@{ StatusCode = 404; Body = [pscustomobject]@{ title = 'Not Found'; status = 404 }; Headers = @{} } + } + + function global:New-V1CompareSession([switch]$Beta) { + if ($Beta) { + return @{ + AuthMode = 'ApiKey'; ApiVersion = 'beta'; ApiHost = 'https://api.example.invalid' + BaseUrl = 'https://api.example.invalid/api'; Region = 'uk'; ConnectedAt = Get-Date + ApiKey = (ConvertTo-SecureString 'beta-test-key-value' -AsPlainText -Force) + } + } + @{ + AuthMode = 'ClientCredentials'; ApiVersion = 'v1'; ApiHost = 'https://api.example.invalid' + BaseUrl = 'https://api.example.invalid'; Region = 'uk'; ConnectedAt = Get-Date + ClientId = 'client-id-example' + ClientSecret = (ConvertTo-SecureString 'client-secret-example-value' -AsPlainText -Force) + AccessToken = (ConvertTo-SecureString 'access-token-example-value' -AsPlainText -Force) + TokenExpiresAt = [datetime]::UtcNow.AddMinutes(50) + } + } + + function global:Set-V1CompareSession([switch]$Beta, [switch]$None) { + $session = if ($None) { $null } else { New-V1CompareSession -Beta:$Beta } + & (Get-Module InforcerCommunity) { param($s) $script:InforcerTenantListCache = $null; $script:InforcerSession = $s } $session + } + + $script:IntuneWarnings = { param($w) @($w | Where-Object { "$_" -match 'Intune' }) } +} + +AfterAll { + foreach ($f in 'Reset-V1CompareRoutes', 'Get-V1CompareResponse', 'New-V1CompareSession', 'Set-V1CompareSession') { Remove-Item "function:global:$f" -ErrorAction SilentlyContinue } + Remove-Variable -Name V1CompareRoutes, V1CompareCalls -Scope Global -ErrorAction SilentlyContinue + & (Get-Module InforcerCommunity) { $script:InforcerSession = $null; $script:InforcerTenantListCache = $null } +} + +Describe 'Compare and export on both logins' { +BeforeEach { + Reset-V1CompareRoutes + Mock -ModuleName InforcerCommunity Invoke-InforcerHttpRequest { Get-V1CompareResponse $Request.Uri } + Mock -ModuleName InforcerCommunity Import-InforcerSettingsCatalog { } +} + +Context 'Get-InforcerDocData tenant selection' { + It 'v1: collects the requested tenant (detailed record), never the first tenant in the list' { + Set-V1CompareSession + $d = InModuleScope InforcerCommunity { Get-InforcerDocData -TenantId 'Fabrikam' 3>$null } + $d.TenantId | Should -BeExactly $T2 + $d.Tenant.tenantFriendlyName | Should -BeExactly 'Fabrikam' + $d.Tenant.msTenantId | Should -Be $T2 + $d.Tenant.PSObject.Properties['secureScore'] | Should -Not -BeNullOrEmpty + $global:V1CompareCalls | Should -Contain "https://api.example.invalid/v1/tenants/$T2" + $global:V1CompareCalls | Should -Not -Contain "https://api.example.invalid/v1/tenants/$T1" + } -TestCases @{ T1 = '00000000-0000-0000-0000-000000000001'; T2 = '00000000-0000-0000-0000-000000000002' } + + It 'beta: collects the requested tenant by client tenant id and keeps the list record as is' { + Set-V1CompareSession -Beta + $d = InModuleScope InforcerCommunity { Get-InforcerDocData -TenantId 2 } + $d.TenantId | Should -Be 2 + $d.TenantId | Should -BeOfType [int] + $d.Tenant.clientTenantId | Should -Be 2 + $d.Tenant.tenantFriendlyName | Should -BeExactly 'Fabrikam' + @($d.Policies).Count | Should -Be 3 + $d.ContainsKey('Notes') | Should -BeFalse + } + + It 'v1: when the tenant record cannot be loaded it warns and names the tenant by id, not another tenant' { + Set-V1CompareSession + $global:V1CompareRoutes['/v1/tenants/00000000-0000-0000-0000-000000000002$'] = @{ Status = 500; Body = '{"title":"Server error","status":500}' } + $r = InModuleScope InforcerCommunity { + $d = Get-InforcerDocData -TenantId '00000000-0000-0000-0000-000000000002' -WarningVariable w -WarningAction SilentlyContinue + @{ D = $d; W = $w } + } + $d = $r.D + $d | Should -Not -BeNullOrEmpty + $d.Tenant | Should -BeNullOrEmpty + @($r.W | Where-Object { "$_" -match 'Could not load details' }).Count | Should -Be 1 + $m = InModuleScope InforcerCommunity -Parameters @{ D = $d } { param($D) ConvertTo-InforcerDocModel -DocData $D } + $m.TenantName | Should -BeExactly "Tenant $T2" + } -TestCases @{ T2 = '00000000-0000-0000-0000-000000000002' } + + It 'v1: a numeric tenant id is InvalidTenantId; a 403 on policies is AccessDenied without API-key wording' { + Set-V1CompareSession + $e1 = InModuleScope InforcerCommunity { $null = Get-InforcerDocData -TenantId 482 -ErrorVariable e -ErrorAction SilentlyContinue; , $e } + @($e1 | Where-Object { $_.FullyQualifiedErrorId -like 'InvalidTenantId*' }).Count | Should -Be 1 + + $global:V1CompareRoutes['/v1/tenants/[0-9a-f-]{36}/policies$'] = @{ Status = 403; Body = '{"title":"Forbidden","status":403,"detail":"forbidden"}' } + $e2 = InModuleScope InforcerCommunity { $null = Get-InforcerDocData -TenantId 'Fabrikam' -ErrorVariable e -ErrorAction SilentlyContinue; , $e } + $err = @($e2 | Where-Object { $_.FullyQualifiedErrorId -like 'AccessDenied*' }) + $err.Count | Should -Be 1 + $err[0].Exception.Message | Should -Not -Match 'API key' + } +} + +Context 'Select-InforcerBaselinePolicies' { + It 'v1: keeps policies matched by matchingSubjectPolicies id even when the name differs' { + Set-V1CompareSession + $kept = InModuleScope InforcerCommunity { + $d = Get-InforcerDocData -TenantId 'Fabrikam' 3>$null + $name = Select-InforcerBaselinePolicies -DocData $d -BaselineId 'Contoso Baseline' 3>$null 6>$null + [pscustomobject]@{ Name = $name; Policies = @($d.Policies) } + } + $kept.Name | Should -BeExactly 'Contoso Baseline' + @($kept.Policies.displayName) | Should -Be @('Block legacy auth (tenant copy)') + @($global:V1CompareCalls | Where-Object { $_ -match "/v1/tenants/$T2/alignmentDetails\?baselineId=$B1$" }).Count | Should -Be 1 + } -TestCases @{ T2 = '00000000-0000-0000-0000-000000000002'; B1 = '00000000-0000-0000-0000-0000000000b1' } + + It 'beta: matches by policy name against the client tenant id endpoint, as before' { + Set-V1CompareSession -Beta + $kept = InModuleScope InforcerCommunity { + $d = Get-InforcerDocData -TenantId 2 + $null = Select-InforcerBaselinePolicies -DocData $d -BaselineId '00000000-0000-0000-0000-0000000000b1' 3>$null 6>$null + @($d.Policies) + } + @($kept.displayName) | Should -Be @('Block legacy auth') + @($global:V1CompareCalls | Where-Object { $_ -match '/beta/tenants/2/alignmentDetails\?customBaselineId=' }).Count | Should -Be 1 + } +} + +Context 'ConvertTo-InforcerDocModel' { + It 'ProfileType comes from the v1 policy type name; beta (no type name in the API) stays empty' { + Set-V1CompareSession + $v1 = InModuleScope InforcerCommunity { ConvertTo-InforcerDocModel -DocData (Get-InforcerDocData -TenantId 'Fabrikam' 3>$null) } + $v1.Products['Defender'].Categories['Endpoint security / Antivirus'][0].Basics.ProfileType | Should -BeExactly 'Antivirus Exclusions' + $v1.Products['Defender'].Categories['Endpoint security / Antivirus'][0].Basics.Id | Should -Be '00000000-0000-0000-0000-0000000000d1' + $v1.TenantName | Should -BeExactly 'Fabrikam' + + Set-V1CompareSession -Beta + $beta = InModuleScope InforcerCommunity { ConvertTo-InforcerDocModel -DocData (Get-InforcerDocData -TenantId 2) } + $beta.Products['Intune'].Categories['Devices / Windows'][0].Basics.ProfileType | Should -BeExactly '' + $beta.Products['Intune'].Categories['Devices / Windows'][0].Basics.Id | Should -Be '1001' + $beta.ContainsKey('Notes') | Should -BeFalse + } +} + +Context 'Export-InforcerTenantDocumentation' { + It 'v1: one Intune warning, and the DocModel carries the note' { + Set-V1CompareSession + $m = Export-InforcerTenantDocumentation -TenantId $T2 -WarningVariable w -WarningAction SilentlyContinue 6>$null + (& $IntuneWarnings $w).Count | Should -Be 1 + @($m.Notes).Count | Should -Be 1 + $m.Notes[0] | Should -Match 'v1 API' + $m.Notes[0] | Should -Match 'no Intune policies' + $m.Notes[0] | Should -Match 'Connect-Inforcer -ApiKey' + $m.Notes[0] | Should -Not -Match 'reported' + $m.TenantName | Should -BeExactly 'Fabrikam' + } -TestCases @{ T2 = '00000000-0000-0000-0000-000000000002' } + + It 'beta: no Intune warning and no Notes key' { + Set-V1CompareSession -Beta + $m = Export-InforcerTenantDocumentation -TenantId 2 -WarningVariable w -WarningAction SilentlyContinue 6>$null + (& $IntuneWarnings $w).Count | Should -Be 0 + $m.ContainsKey('Notes') | Should -BeFalse + $m.TenantName | Should -BeExactly 'Fabrikam' + } + + It 'pipeline: a v1 tenant binds via MsTenantId, a beta tenant still via ClientTenantId' { + Set-V1CompareSession + $m = Get-InforcerTenant -TenantId 'Fabrikam' | Export-InforcerTenantDocumentation 3>$null 6>$null + $m.TenantId | Should -BeExactly $T2 + + Set-V1CompareSession -Beta + $m = [pscustomobject]@{ ClientTenantId = 2; MsTenantId = $T1 } | Export-InforcerTenantDocumentation 3>$null 6>$null + $m.TenantId | Should -Be 2 + } -TestCases @{ T1 = '00000000-0000-0000-0000-000000000001'; T2 = '00000000-0000-0000-0000-000000000002' } +} + +Context 'Compare-InforcerEnvironments' { + It 'v1: both sides get the note in the model and a warning each' { + Set-V1CompareSession + $model = Compare-InforcerEnvironments -SourceTenantId 'Contoso' -DestinationTenantId 'Fabrikam' -WarningVariable w -WarningAction SilentlyContinue 6>$null + (& $IntuneWarnings $w).Count | Should -Be 2 + @($model.Notes).Count | Should -Be 2 + $model.Notes[0] | Should -BeLike 'Source (Contoso): *Intune*' + $model.Notes[1] | Should -BeLike 'Destination (Fabrikam): *Intune*' + } + + It 'mixed sessions: each side calls its own API, only the v1 side gets the note, and the session is restored' { + Set-V1CompareSession -None + $src = New-V1CompareSession -Beta + $dst = New-V1CompareSession + $model = Compare-InforcerEnvironments -SourceTenantId 1 -DestinationTenantId 'Fabrikam' -SourceSession $src -DestinationSession $dst -WarningVariable w -WarningAction SilentlyContinue 6>$null + $model | Should -Not -BeNullOrEmpty + $model.SourceName | Should -BeExactly 'Contoso' + $model.DestinationName | Should -BeExactly 'Fabrikam' + @($model.Notes).Count | Should -Be 1 + $model.Notes[0] | Should -BeLike 'Destination (Fabrikam): *' + $global:V1CompareCalls | Should -Contain 'https://api.example.invalid/api/beta/tenants/1/policies' + $global:V1CompareCalls | Should -Contain "https://api.example.invalid/v1/tenants/$T2/policies" + (InModuleScope InforcerCommunity { $null -eq $script:InforcerSession }) | Should -BeTrue + } -TestCases @{ T2 = '00000000-0000-0000-0000-000000000002' } + + It 'baseline owner: resolved in the session of each side (beta client tenant id, v1 Microsoft tenant id)' { + Set-V1CompareSession -None + $src = New-V1CompareSession -Beta + $dst = New-V1CompareSession + $null = Compare-InforcerEnvironments -SourceBaselineId 'Contoso Baseline' -DestinationBaselineId 'Contoso Baseline' -SourceSession $src -DestinationSession $dst -ErrorVariable e -ErrorAction SilentlyContinue 3>$null 6>$null + @($e | Where-Object { $_.FullyQualifiedErrorId -like 'BaselineOwnerNotFound*' -or $_.FullyQualifiedErrorId -like 'InvalidTenantId*' }).Count | Should -Be 0 + $global:V1CompareCalls | Should -Contain 'https://api.example.invalid/api/beta/tenants/1/policies' + $global:V1CompareCalls | Should -Contain "https://api.example.invalid/v1/tenants/$T1/policies" + } -TestCases @{ T1 = '00000000-0000-0000-0000-000000000001' } +} +} diff --git a/module/Private/ConvertTo-InforcerComparisonHtml.ps1 b/module/Private/ConvertTo-InforcerComparisonHtml.ps1 index 43f138d..5577fdd 100644 --- a/module/Private/ConvertTo-InforcerComparisonHtml.ps1 +++ b/module/Private/ConvertTo-InforcerComparisonHtml.ps1 @@ -847,6 +847,10 @@ h3:first-child { margin-top: var(--sp-4); } [void]$sb.AppendLine(' ') [void]$sb.AppendLine("
Generated $generatedAt
$totalItems settings compared
") [void]$sb.AppendLine('') + foreach ($note in @($ComparisonModel.Notes)) { + if ([string]::IsNullOrWhiteSpace($note)) { continue } + [void]$sb.AppendLine("
Note: $([System.Net.WebUtility]::HtmlEncode($note))
") + } # ── Action bar — leads with what needs attention ─────────────────────── [void]$sb.AppendLine('
') diff --git a/module/Private/ConvertTo-InforcerDocModel.ps1 b/module/Private/ConvertTo-InforcerDocModel.ps1 index b2f85a3..4268490 100644 --- a/module/Private/ConvertTo-InforcerDocModel.ps1 +++ b/module/Private/ConvertTo-InforcerDocModel.ps1 @@ -142,7 +142,7 @@ function ConvertTo-InforcerDocModel { no API calls (per NORM-06). .PARAMETER DocData Hashtable from Get-InforcerDocData containing: - Tenant, Baselines, Policies, TenantId, CollectedAt. + Tenant, Baselines, Policies, TenantId, CollectedAt, and optionally Notes (copied to the DocModel). .OUTPUTS Hashtable representing the DocModel tree (per D-10). #> @@ -255,13 +255,9 @@ function ConvertTo-InforcerDocModel { $policies = $DocData.Policies $baselines = $DocData.Baselines - # Tenant metadata - $tenantName = '' - if ($null -ne $tenant) { - $tenantName = $tenant.tenantFriendlyName - if ([string]::IsNullOrWhiteSpace($tenantName)) { $tenantName = $tenant.tenantDnsName } - if ([string]::IsNullOrWhiteSpace($tenantName)) { $tenantName = "Tenant $($DocData.TenantId)" } - } + $tenantName = if ($null -ne $tenant) { $tenant.tenantFriendlyName } + if ([string]::IsNullOrWhiteSpace($tenantName) -and $null -ne $tenant) { $tenantName = $tenant.tenantDnsName } + if ([string]::IsNullOrWhiteSpace($tenantName)) { $tenantName = "Tenant $($DocData.TenantId)" } # Collect all baseline names for this tenant $baselineNames = [System.Collections.Generic.List[string]]::new() @@ -495,7 +491,7 @@ function ConvertTo-InforcerDocModel { } # Return the DocModel (per D-10, NORM-06) - @{ + $docModel = @{ TenantName = $tenantName TenantId = $DocData.TenantId GeneratedAt = $DocData.CollectedAt @@ -503,4 +499,6 @@ function ConvertTo-InforcerDocModel { Baselines = $baselineNames Products = $products } + if ($DocData.Notes) { $docModel['Notes'] = @($DocData.Notes) } + $docModel } diff --git a/module/Private/ConvertTo-InforcerHtml.ps1 b/module/Private/ConvertTo-InforcerHtml.ps1 index fd8d1e2..b8743da 100644 --- a/module/Private/ConvertTo-InforcerHtml.ps1 +++ b/module/Private/ConvertTo-InforcerHtml.ps1 @@ -900,6 +900,10 @@ tr:hover td { background: var(--row-hover); } [void]$sb.AppendLine("Tag: $filterTagEsc") } [void]$sb.AppendLine('
') + foreach ($note in @($DocModel.Notes)) { + if ([string]::IsNullOrWhiteSpace($note)) { continue } + [void]$sb.AppendLine("
Note: $([System.Net.WebUtility]::HtmlEncode($note))
") + } [void]$sb.AppendLine('