diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 656706b..fce5d80 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -19,12 +19,33 @@ jobs: run: Install-Module -Name Pester -MinimumVersion 5.0 -Force -Scope CurrentUser shell: pwsh - - name: Run Consistency.Tests.ps1 + - name: Run all Pester test files run: | - $result = Invoke-Pester ./Tests/Consistency.Tests.ps1 -PassThru -Output Detailed + # Previously this only ran Consistency.Tests.ps1; the other 5 files in Tests/ + # (DocModel, GraphResolution, Renderers, SettingsCatalog, SettingsCatalogCache — + # ~237 tests) were never exercised in CI. Run the whole Tests/ directory. + $result = Invoke-Pester ./Tests/ -PassThru -Output Detailed if ($result.FailedCount -gt 0) { exit 1 } shell: pwsh + smoke-test: + name: Smoke (Test-AllCmdlets.ps1) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Run Test-AllCmdlets.ps1 + # No live API session — the smoke runner only verifies cmdlets are exported, + # have help, and parameter binding works. Real-API tests require a key (manual). + run: | + Import-Module ./module/InforcerCommunity.psd1 -Force + if (Test-Path ./scripts/Test-AllCmdlets.ps1) { + pwsh -NoProfile -File ./scripts/Test-AllCmdlets.ps1 + } else { + Write-Host 'No Test-AllCmdlets.ps1 — skipping.' + } + shell: pwsh + static-analysis: name: PSScriptAnalyzer runs-on: ubuntu-latest @@ -35,12 +56,32 @@ jobs: run: Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser shell: pwsh - - name: Run PSScriptAnalyzer + - name: Run PSScriptAnalyzer on module run: | $results = Invoke-ScriptAnalyzer -Path ./module -Recurse -Severity Error if ($results) { $results | Format-Table -AutoSize - throw "PSScriptAnalyzer found $($results.Count) error(s)." + throw "PSScriptAnalyzer found $($results.Count) error(s) in ./module." + } + Write-Host 'PSScriptAnalyzer (./module): no errors found.' + shell: pwsh + + - name: Run PSScriptAnalyzer on tests + scripts (advisory) + # Don't fail the build on these — but surface findings so broken test files / + # smoke runners don't silently degrade. + run: | + # Run per-path: -Path takes a single string, not an array. Comma-separation lands + # as [object[]] which the parameter doesn't bind to on Linux runners. + $results = @() + foreach ($p in @('./Tests', './scripts')) { + if (Test-Path $p) { + $results += Invoke-ScriptAnalyzer -Path $p -Recurse -Severity Error -ErrorAction SilentlyContinue + } + } + if ($results) { + $results | Format-Table -AutoSize + Write-Warning "PSScriptAnalyzer found $($results.Count) error(s) outside ./module (advisory — not failing the build)." + } else { + Write-Host 'PSScriptAnalyzer (./Tests, ./scripts): no errors.' } - Write-Host 'PSScriptAnalyzer: no errors found.' shell: pwsh diff --git a/.gitignore b/.gitignore index 47f06c8..634992c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,27 +37,23 @@ TestResults/ # AI tooling and planning .claude/ +.claire/ .planning/ .superpowers/ CLAUDE.md /docs/superpowers/ +# Session handoff / brief docs (internal-only, may contain API keys, personal paths, tenant data) +SESSION-HANDOFF*.md +*-HANDOFF*.md +*-handoff*.md + # Cursor .cursor/ -# Github -/.github/copilot-instructions.md - -# Agents -/AGENTS.md -/SKILLS.md - # ignore test files /**/*.html -# Settings Catalog data (62.5 MB -- copy from IntuneSettingsCatalogViewer sibling repo) -module/data/settings.json - # Sample data /scripts/sample-data/ /**/sample-data/ @@ -68,59 +64,25 @@ module/data/settings.json # TODO (local dev notes) TODO.md +# Local scratch / probe notes (never commit — may contain environment URLs or sensitive context) +/docs info.md +/SCRATCH.md +/scratch.md +NOTES.local.md + +# Manual / live test harnesses — disposable, may contain DEV/UAT URLs, API keys, tenant data +# or developer-specific probes. Keep these local-only; the public repo only ships the +# automated Pester suites at Tests/*.Tests.ps1. +/Tests/Manual/ + # Generated documentation output *.xlsx *.md.bak api-feedback.md -api-feedback-evidence/10-compliance-script-data.json -api-feedback-evidence/10-audit-events.json -api-feedback-evidence/09-pagination-metadata.json -api-feedback-evidence/09-compliance-script.json -api-feedback-evidence/08-alignment-scores.json -api-feedback-evidence/08-alignment-scores-format.json -api-feedback-evidence/07-groups-404.json -api-feedback-evidence/06-groups-visibility.json -api-feedback-evidence/05-groups-member-upn.json -api-feedback-evidence/04-groups-search.json -api-feedback-evidence/03-tenants-single-vs-list.json -api-feedback-evidence/03-tenants-json-shape.json -api-feedback-evidence/02-tenants-single.json -api-feedback-evidence/02-tenants-list.json -api-feedback-evidence/01-tenants-single-resource.json -api-feedback-evidence/01-tenants-list.json -.planning-archive/research/SUMMARY.md -.planning-archive/research/STACK.md -.planning-archive/research/PITFALLS.md -.planning-archive/research/FEATURES.md -.planning-archive/research/ARCHITECTURE.md -.planning-archive/phases/03-public-cmdlet-and-module-integration/03-VERIFICATION.md -.planning-archive/phases/03-public-cmdlet-and-module-integration/03-RESEARCH.md -.planning-archive/phases/03-public-cmdlet-and-module-integration/03-DISCUSSION-LOG.md -.planning-archive/phases/03-public-cmdlet-and-module-integration/03-CONTEXT.md -.planning-archive/phases/03-public-cmdlet-and-module-integration/03-02-PLAN.md -.planning-archive/phases/03-public-cmdlet-and-module-integration/03-01-SUMMARY.md -.planning-archive/phases/03-public-cmdlet-and-module-integration/03-01-PLAN.md -.planning-archive/phases/02-output-format-renderers/02-VERIFICATION.md -.planning-archive/phases/02-output-format-renderers/02-RESEARCH.md -.planning-archive/phases/02-output-format-renderers/02-DISCUSSION-LOG.md -.planning-archive/phases/02-output-format-renderers/02-CONTEXT.md -.planning-archive/phases/02-output-format-renderers/02-03-SUMMARY.md -.planning-archive/phases/02-output-format-renderers/02-03-PLAN.md -.planning-archive/phases/02-output-format-renderers/02-02-SUMMARY.md -.planning-archive/phases/02-output-format-renderers/02-02-PLAN.md -.planning-archive/phases/02-output-format-renderers/02-01-SUMMARY.md -.planning-archive/phases/02-output-format-renderers/02-01-PLAN.md -.planning-archive/phases/01-data-pipeline-and-normalization/01-RESEARCH.md -.planning-archive/phases/01-data-pipeline-and-normalization/01-DISCUSSION-LOG.md -.planning-archive/phases/01-data-pipeline-and-normalization/01-CONTEXT.md -.planning-archive/phases/01-data-pipeline-and-normalization/01-02-SUMMARY.md -.planning-archive/phases/01-data-pipeline-and-normalization/01-02-PLAN.md -.planning-archive/phases/01-data-pipeline-and-normalization/01-01-SUMMARY.md -.planning-archive/phases/01-data-pipeline-and-normalization/01-01-PLAN.md -.planning-archive/STATE.md -.planning-archive/ROADMAP.md -.planning-archive/REVIEW.md -.planning-archive/REQUIREMENTS.md -.planning-archive/PROJECT.md -.planning-archive/config.json +api-feedback-evidence/ +# Internal API feedback / issue reports — never published; may contain DEV URLs, tenant data, +# or call IDs from live runs. +*API-Feedback*.md +*api-feedback*.md +.planning-archive/ scripts/Test-ApiFeedbackItems.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f76cd38..9bbfc6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,42 @@ All notable changes to this project will be documented in this file. -The format follows [Conventional Commits](https://www.conventionalcommits.org/) and this project adheres to [Semantic Versioning](https://semver.org/). Release notes for each version are also generated from git history by the automation pipeline using the same conventional types (feat, fix, docs, refactor, test, etc.). +The format follows [Conventional Commits](https://www.conventionalcommits.org/). Versioning deviates from strict SemVer: every shipped change (feat / fix / perf / non-breaking refactor) bumps MINOR; only breaking changes bump MAJOR; docs/tests/chore-only commits don't bump. There is intentionally no `[Unreleased]` section — every entry is dated at ship time. + +## [0.5.0] - 2026-06-30 + +### Features + +- **New cmdlet: `Get-InforcerReportType`** — lists all available report types (Active User Count, Tenant Audit Report, Copilot Adoption, etc.) from `GET /beta/reports/types`. Filters by `-Key`, `-Tag`, and `-OutputFormat`. Results cached client-side and reused by tab completion; `-Force` refetches. +- **New cmdlet: `Invoke-InforcerReport`** — queues one or more report runs via `POST /beta/reports/runs`, polls until each run is complete, and saves the outputs to disk. Key capabilities: + - **Three execution modes** — default is sync + save to the current directory (or `-OutputPath`); `-NoWait` queues and returns immediately with the run IDs; `-NoSave` polls until terminal without writing files. + - **Auto-open with `-Open`** (aliases `-Show`, `-ShowResult`) — launches each saved file with the OS default handler (HTML → browser, PDF → viewer, CSV/XLSX → spreadsheet). Extensions are allowlisted so the server can't dictate launching an executable. + - **Pipeline-bindable** — `Get-InforcerReportType -Tag Security | Invoke-InforcerReport -OutputFormat csv -TenantId 482` batches every piped report type into one POST. + - **Three-phase progress bar** — queue → poll (with elapsed time and poll count) → download → complete. + - **Friendly input** — `-TenantId` accepts numeric IDs, GUIDs, or tenant names. Unknown report types trigger one auto-refresh of the catalog before failing, so new types ship without forcing a disconnect/reconnect. + - **Required scopes** — `Reports.Read` + `Reports.Run` (+ `Tenants.Read` only when `-TenantId` is a GUID or tenant name). +- **New cmdlet: `Get-InforcerReportRun`** — lists report runs from `GET /beta/reports/runs` (server caps the result at 500 items / last 7 days). `-Wait` with `-RunId` polls until the run is complete and bypasses a ~4-minute lag between completion and list visibility. `-IncludeOutputs` embeds each run's outputs. +- **New cmdlet: `Save-InforcerReportOutput`** — downloads a specific report output to disk via `GET /beta/reports/runs/{runId}/outputs/{outputId}`. Pipeline-friendly with `Invoke-InforcerReport -NoSave` and `Get-InforcerReportRun -IncludeOutputs`. Uses the server's `Content-Disposition` filename (with full Unicode support); `-FileName` overrides. Sanitizes Windows reserved names (`CON`, `PRN`, `AUX`, etc.) and caps length at 200 characters while preserving the extension. Accepts optional `-ReportType`, `-OutputFormat`, and `-TenantId` (alias `-ClientTenantId`) pipeline-bindable pass-through parameters so the emitted result matches the `InforcerCommunity.ReportRunResult` format view when piped from upstream cmdlets. +- **Dynamic tab completion for `-AssessmentId` on `Invoke-InforcerReport`** — three permission-aware states: not connected (hint to run `Connect-Inforcer`), connected without scope (hint about `Assessments.Read`, denial cached so subsequent TABs are instant), connected with scope (friendly names in the dropdown, opaque assessment ID inserted on selection, `name — id` shown as the tooltip). +- **`Connect-Inforcer` primes the Reports catalog cache** on a successful connect (best-effort, 4-second budget, silent on missing scope) so tab completion on `Invoke-InforcerReport -ReportType` shows live keys on the very first attempt. +- **API errors now include field-level details.** When the server returns a structured `errors[]` array, each entry is rendered into the error text — `Invoke-InforcerReport` against a tenant outside the key's scope now reads *"Validation failed — tenants.includeTenants contains tenant X that is not in the API key's scope"* instead of the generic *"see errors for details"* placeholder. The `x-correlation-id` response header is also captured (visible in verbose output and included in error records) for support tickets. + +### Documentation + +- Sections for all four Reports cmdlets added to `docs/CMDLET-REFERENCE.md`; Reports endpoints and schemas added to `docs/API-REFERENCE.md`; the new cmdlets (plus `Get-InforcerSupportedEventType`, which was missing) added to the README public surface table. + +### Bug Fixes + +- **`Connect-Inforcer` now reports a meaningful error on HTTP 401 in PowerShell 7.** Previously the error handler only fired on the PowerShell 5.1 exception type, so PS7 users saw an empty error message when the API key was rejected. Now extracts the status code and message from either exception path. +- **`Connect-Inforcer` accepts any valid API key, regardless of scope.** Customers with keys scoped only to `Reports.Read`, `Assessments.Read`, or `Audit.Read` previously couldn't connect because the validation probe required `Baselines.Read` / `Tenants.Read` and a 403 was treated as failure. The cmdlet now distinguishes "key rejected" from "key valid but scope missing" and accepts the latter. +- **`-AssessmentId` dynamic completer caches denial on HTTP 401 as well as 403.** The gateway returns 401 for both subscription-level rejection and missing scopes; previously only 403 was cached, so the second TAB still hit the network. Both are now cached. +- **JSON depth 100 enforced** in three pre-existing helpers (`Get-InforcerComparisonData`, `Compare-InforcerDocModels`, `Resolve-InforcerGraphEnrichment`) and two cache-metadata writes in `Get-InforcerSettingsCatalogPath` that had no explicit depth — prevents truncation on deep policy structures. +- **`Invoke-InforcerReport -AssessmentId ''` now throws a clear local error** instead of forwarding `assessment-id=''` to the server (which produced a generic validation failure). Empty / whitespace-only values are rejected up front with a hint to run `Get-InforcerAssessment`. + +### Tests + +- 6 new Pester tests for the `errors[]` array renderer covering field/code/message rendering, plain-string entries, alternate property aliases, null/empty inputs, and unrecognized object shapes. Total suite: 394 pass / 0 fail / 2 legit skips. +- Pre-merge live smoke harness (`Tests/Manual/Live-ApiSmoke.ps1`, gitignored) verified all four Reports cmdlets end-to-end against the DEV API: 24/24 PASS. ## [0.4.0] - 2026-05-15 diff --git a/README.md b/README.md index ee1c94f..0d21d24 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,18 @@ Disconnect-Inforcer | **Get-InforcerTenantPolicies** | Retrieves policies for a specified tenant. | | **Get-InforcerAlignmentDetails** | Retrieves alignment scores (table or raw format). | | **Get-InforcerAuditEvent** | Retrieves audit events (optional EventType, date range; -EventType has tab completion). | +| **Get-InforcerSupportedEventType** | Returns the canonical list of audit event types accepted by `Get-InforcerAuditEvent -EventType`. Drives the tab completer; cached after first call. | | **Get-InforcerUser** | Retrieves users from a tenant (list/search or detail by UserId). | | **Get-InforcerGroup** | Retrieves Entra ID groups from a tenant (list/search or detail by GroupId). | | **Get-InforcerRole** | Retrieves Entra ID directory role definitions from a tenant. | | **Export-InforcerTenantDocumentation** | Generates comprehensive tenant documentation in HTML, Markdown, or Excel format. | | **Compare-InforcerEnvironments** | Compares two tenants' Intune configuration and generates an interactive HTML comparison report. Supports baseline-scoped comparison via `-SourceBaselineId` / `-DestinationBaselineId` with automatic baseline owner resolution. | +| **Get-InforcerAssessment** | Lists available assessments (CIS, Essential Eight, etc.). | +| **Invoke-InforcerAssessment** | Runs an assessment against one or more tenants. Supports HTML/CSV export. | +| **Get-InforcerReportType** | Lists the report catalog from the Reports API. Cached after first call; supports `-Key`, `-Tag`, and `-OutputFormat` filters. | +| **Invoke-InforcerReport** | Queues a report run, polls until complete, and saves outputs to disk. Use `-NoWait` for async or `-NoSave` to download metadata only. | +| **Get-InforcerReportRun** | Lists report runs (last 7 days, capped at 500). Use `-Wait` with `-RunId` to bypass the 4-minute list propagation lag. | +| **Save-InforcerReportOutput** | Downloads a report output to disk using the server's `Content-Disposition` filename. Pipeline-friendly for bulk download. | For full parameter details and example output, see **[Cmdlet Reference](docs/CMDLET-REFERENCE.md)**. For detailed API schemas and response structures, see **[API Reference](docs/API-REFERENCE.md)**. diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index babdaad..c807360 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -30,14 +30,15 @@ Describe 'Consistency contract' { $path = Get-InforcerCommunityManifestPath Import-Module $path -Force $script:exported = (Get-Module -Name 'InforcerCommunity').ExportedCommands.Keys - $script:expectedCount = 16 + $script:expectedCount = 20 $script:expectedNames = @( 'Connect-Inforcer', 'Disconnect-Inforcer', 'Test-InforcerConnection', 'Get-InforcerTenant', 'Get-InforcerBaseline', 'Get-InforcerTenantPolicies', 'Get-InforcerAlignmentDetails', 'Get-InforcerAuditEvent', 'Get-InforcerSupportedEventType', 'Get-InforcerUser', 'Get-InforcerGroup', 'Get-InforcerRole', 'Export-InforcerTenantDocumentation', 'Compare-InforcerEnvironments', - 'Get-InforcerAssessment', 'Invoke-InforcerAssessment' + 'Get-InforcerAssessment', 'Invoke-InforcerAssessment', + 'Get-InforcerReportType', 'Invoke-InforcerReport', 'Get-InforcerReportRun', 'Save-InforcerReportOutput' ) $script:expectedParameters = @{ 'Connect-Inforcer' = @('ApiKey', 'Region', 'BaseUrl', 'FetchGraphData', 'PassThru') @@ -56,6 +57,10 @@ Describe 'Consistency contract' { 'Compare-InforcerEnvironments' = @('SourceTenantId', 'DestinationTenantId', 'SourceSession', 'DestinationSession', 'SourceBaselineId', 'DestinationBaselineId', 'IncludingAssignments', 'SettingsCatalogPath', 'FetchGraphData', 'ExcludeOS', 'PolicyNameFilter', 'OutputPath') 'Get-InforcerAssessment' = @('Format', 'OutputType') 'Invoke-InforcerAssessment' = @('TenantId', 'AssessmentId', 'OutputType') + 'Get-InforcerReportType' = @('Key', 'Tag', 'OutputFormat', 'Force', 'Format', 'OutputType') + 'Invoke-InforcerReport' = @('ReportType', 'OutputFormat', 'TenantId', 'ReportPeriod', 'AssessmentId', 'Parameter', 'Collate', 'NoWait', 'NoSave', 'OutputPath', 'TimeoutSeconds', 'PollIntervalSeconds', 'Format', 'OutputType') + 'Get-InforcerReportRun' = @('RunId', 'Wait', 'IncludeOutputs', 'TimeoutSeconds', 'PollIntervalSeconds', 'Format', 'OutputType') + 'Save-InforcerReportOutput' = @('RunId', 'OutputId', 'ReportType', 'OutputFormat', 'TenantId', 'OutputPath', 'FileName', 'OutputType') } } @@ -207,6 +212,31 @@ Describe 'No-silent-failure contract' { Invoke-InforcerAssessment -TenantId 1 -AssessmentId 'test' -ErrorVariable err -ErrorAction SilentlyContinue $err | Should -Not -BeNullOrEmpty -Because 'should report not connected, not return silence' } + + It 'Get-InforcerReportType produces an error when not connected' { + $err = $null + Get-InforcerReportType -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -Not -BeNullOrEmpty -Because 'should report not connected, not return silence' + } + + It 'Invoke-InforcerReport produces an error when not connected' { + $err = $null + Invoke-InforcerReport -ReportType 'ActiveUserCount' -OutputFormat csv -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -Not -BeNullOrEmpty -Because 'should report not connected, not return silence' + } + + It 'Get-InforcerReportRun produces an error when not connected' { + $err = $null + Get-InforcerReportRun -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -Not -BeNullOrEmpty -Because 'should report not connected, not return silence' + } + + It 'Save-InforcerReportOutput produces an error when not connected' { + $err = $null + $guid = [guid]::NewGuid() + Save-InforcerReportOutput -RunId $guid -OutputId 'out-1' -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -Not -BeNullOrEmpty -Because 'should report not connected, not return silence' + } } Describe 'Parameter binding and behavior' { @@ -228,7 +258,7 @@ Describe 'Parameter binding and behavior' { $hasError = $null -ne $err -and (@($err).Count -gt 0) ($hasOutput -or $hasError) | Should -BeTrue -Because 'Connect-Inforcer must not silently do nothing' if ($hasError -and $err[0].ToString() -match 'Cannot bind|Parameter.*not found|Unknown parameter') { - Set-ItResult -Inconclusive -Because 'Parameter binding failed; check parameter names' + throw "Parameter binding failed (contract regression): $($err[0].ToString())" } } @@ -240,7 +270,7 @@ Describe 'Parameter binding and behavior' { $hasError = $err.Count -gt 0 ($hasOutput -or $hasError) | Should -BeTrue -Because 'Get-InforcerTenant must not silently do nothing' if ($hasError -and $err[0].ToString() -match 'Cannot bind|Parameter.*not found|Unknown parameter') { - Set-ItResult -Inconclusive -Because 'Parameter binding failed' + throw "Parameter binding failed (contract regression): $($err[0].ToString())" } } @@ -252,7 +282,7 @@ Describe 'Parameter binding and behavior' { $hasError = $err.Count -gt 0 ($hasOutput -or $hasError) | Should -BeTrue -Because 'Get-InforcerBaseline must not silently do nothing' if ($hasError -and $err[0].ToString() -match 'Cannot bind|Parameter.*not found|Unknown parameter') { - Set-ItResult -Inconclusive -Because 'Parameter binding failed' + throw "Parameter binding failed (contract regression): $($err[0].ToString())" } } @@ -264,7 +294,7 @@ Describe 'Parameter binding and behavior' { $hasError = $err.Count -gt 0 ($hasOutput -or $hasError) | Should -BeTrue -Because 'Get-InforcerTenantPolicies must not silently do nothing' if ($hasError -and $err[0].ToString() -match 'Cannot bind|Parameter.*not found|Unknown parameter') { - Set-ItResult -Inconclusive -Because 'Parameter binding failed' + throw "Parameter binding failed (contract regression): $($err[0].ToString())" } } @@ -276,7 +306,7 @@ Describe 'Parameter binding and behavior' { $hasError = $err.Count -gt 0 ($hasOutput -or $hasError) | Should -BeTrue -Because 'Get-InforcerAlignmentDetails must not silently do nothing' if ($hasError -and $err[0].ToString() -match 'Cannot bind|Parameter.*not found|Unknown parameter') { - Set-ItResult -Inconclusive -Because 'Parameter binding failed' + throw "Parameter binding failed (contract regression): $($err[0].ToString())" } } @@ -288,7 +318,7 @@ Describe 'Parameter binding and behavior' { $hasError = $err.Count -gt 0 ($hasOutput -or $hasError) | Should -BeTrue -Because 'Get-InforcerAuditEvent must not silently do nothing' if ($hasError -and $err[0].ToString() -match 'Cannot bind|Parameter.*not found|Unknown parameter') { - Set-ItResult -Inconclusive -Because 'Parameter binding failed' + throw "Parameter binding failed (contract regression): $($err[0].ToString())" } } @@ -412,7 +442,7 @@ Describe 'Parameter binding and behavior' { $hasError = $err.Count -gt 0 ($hasOutput -or $hasError) | Should -BeTrue -Because 'Export-InforcerTenantDocumentation must not silently do nothing' if ($hasError -and $err[0].ToString() -match 'Cannot bind|Parameter.*not found|Unknown parameter') { - Set-ItResult -Inconclusive -Because 'Parameter binding failed; check parameter names' + throw "Parameter binding failed (contract regression): $($err[0].ToString())" } } @@ -888,8 +918,10 @@ Describe 'Private helpers (via module scope)' { $assessment.Id | Should -Be 'abc123' $assessment.Name | Should -Be 'Copilot Readiness' $assessment.AssessmentType | Should -Be 'platform' - $assessment.tags | Should -BeOfType [string] - $assessment.tags | Should -Be 'copilot, inforcer, platform' + # Raw 'tags' shape is preserved (array stays an array). Format.ps1xml handles + # display joining. This was previously mutated to a comma-string but that broke + # downstream filter logic that expects the raw shape. + @($assessment.tags) | Should -Be @('copilot','inforcer','platform') } } @@ -911,7 +943,8 @@ Describe 'Private helpers (via module scope)' { tags = @(); lastUpdated = $null; created = $null } $null = Add-InforcerPropertyAliases -InputObject $assessment -ObjectType Assessment - $assessment.tags | Should -Be '' + # Empty array is preserved (not mutated to empty string). + @($assessment.tags).Count | Should -Be 0 } } } @@ -1004,4 +1037,979 @@ Describe 'Private helpers (via module scope)' { } } } + + Context 'Resolve-InforcerReportOutputFileName' { + It 'Parses plain filename=' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportOutputFileName -ContentDisposition 'attachment; filename=Report.csv' + $r | Should -Be 'Report.csv' + } + } + + It 'Parses quoted filename with spaces' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportOutputFileName -ContentDisposition 'attachment; filename="my report.csv"' + $r | Should -Be 'my report.csv' + } + } + + It 'Parses RFC 5987 filename* with UTF-8 percent-encoding' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportOutputFileName -ContentDisposition "attachment; filename*=UTF-8''na%C3%AFve.txt" + $r | Should -Be 'naïve.txt' + } + } + + It 'Prefers filename* over filename when both present' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportOutputFileName -ContentDisposition "attachment; filename=fallback.txt; filename*=UTF-8''na%C3%AFve.txt" + $r | Should -Be 'naïve.txt' + } + } + + It 'Strips path components for safety' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportOutputFileName -ContentDisposition 'attachment; filename=../../etc/passwd' + $r | Should -Be 'passwd' + } + } + + It 'Sanitizes reserved characters' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportOutputFileName -ContentDisposition 'attachment; filename="weird:|name.txt"' + $r | Should -Be 'weird__name.txt' + } + } + + It 'Falls back to default when header is empty' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportOutputFileName -ContentDisposition '' -DefaultName 'my-default.bin' + $r | Should -Be 'my-default.bin' + } + } + + It 'Falls back to default when header is null' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportOutputFileName -ContentDisposition $null + $r | Should -Be 'output' + } + } + } + + Context 'Get-InforcerHeaderValue' { + It 'Returns null for null headers' { + & (Get-Module InforcerCommunity) { + Get-InforcerHeaderValue -Headers $null -Name 'x-correlation-id' | Should -BeNullOrEmpty + } + } + + It 'Reads from hashtable case-insensitively (lower-case lookup)' { + & (Get-Module InforcerCommunity) { + $h = @{ 'X-Correlation-Id' = 'abc-123' } + Get-InforcerHeaderValue -Headers $h -Name 'x-correlation-id' | Should -Be 'abc-123' + } + } + + It 'Reads from hashtable case-insensitively (mixed-case lookup)' { + & (Get-Module InforcerCommunity) { + $h = @{ 'x-correlation-id' = 'abc-123' } + Get-InforcerHeaderValue -Headers $h -Name 'X-Correlation-Id' | Should -Be 'abc-123' + } + } + + It 'Unwraps single-element array values (Invoke-RestMethod shape)' { + & (Get-Module InforcerCommunity) { + $h = @{ 'x-correlation-id' = @('abc-123') } + Get-InforcerHeaderValue -Headers $h -Name 'x-correlation-id' | Should -Be 'abc-123' + } + } + } + + Context 'Resolve-InforcerReportTypeSchema (with seeded cache)' { + BeforeAll { + & (Get-Module InforcerCommunity) { + $script:InforcerReportTypeCache = @( + [pscustomobject]@{ + key = 'ActiveUserCount' + outputFormats = @('csv','json') + collatable = $true + parameters = @() + } + [pscustomobject]@{ + key = 'TenantAuditReport' + outputFormats = @('html','pdf') + collatable = $false + parameters = @() + } + [pscustomobject]@{ + key = 'CopilotAdoption' + outputFormats = @('csv','json') + collatable = $true + parameters = @([pscustomobject]@{ key = 'report-period' }) + } + [pscustomobject]@{ + key = 'Assessment' + outputFormats = @('pdf') + collatable = $false + parameters = @([pscustomobject]@{ key = 'assessment-id' }) + } + ) + } + } + + AfterAll { + & (Get-Module InforcerCommunity) { + $script:InforcerReportTypeCache = $null + } + } + + It 'Resolves a valid (type, format) pair into an API entry' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportTypeSchema -ReportType ActiveUserCount -OutputFormat csv + $r.TypeKey | Should -Be 'ActiveUserCount' + $r.OutputFormat | Should -Be 'csv' + $r.Entry.type | Should -Be 'ActiveUserCount' + $r.Entry.outputFormat | Should -Be 'csv' + } + } + + It 'Auto-defaults report-period for CopilotAdoption' { + & (Get-Module InforcerCommunity) { + $r = Resolve-InforcerReportTypeSchema -ReportType CopilotAdoption -OutputFormat csv + $r.Parameters['report-period'] | Should -Be '30' + $r.Entry.parameters['report-period'] | Should -Be '30' + } + } + + It 'Throws on unknown report type' { + & (Get-Module InforcerCommunity) { + { Resolve-InforcerReportTypeSchema -ReportType NotARealType -OutputFormat csv } | + Should -Throw '*Unknown report type*' + } + } + + It 'Throws on unsupported output format for the type' { + & (Get-Module InforcerCommunity) { + { Resolve-InforcerReportTypeSchema -ReportType ActiveUserCount -OutputFormat pdf } | + Should -Throw '*does not support output format*' + } + } + + It 'Throws when -Collate set on non-collatable type' { + & (Get-Module InforcerCommunity) { + { Resolve-InforcerReportTypeSchema -ReportType TenantAuditReport -OutputFormat pdf -Collate } | + Should -Throw '*does not support collation*' + } + } + + It 'Throws when Assessment requested without -AssessmentId' { + & (Get-Module InforcerCommunity) { + { Resolve-InforcerReportTypeSchema -ReportType Assessment -OutputFormat pdf } | + Should -Throw "*requires -AssessmentId*" + } + } + + It 'Accepts Assessment with -AssessmentId (alphanumeric string, not a GUID)' { + & (Get-Module InforcerCommunity) { + # Real Inforcer assessment IDs are alphanumeric strings, e.g. l1f8wd29pl44pp1j66r9 + $id = 'l1f8wd29pl44pp1j66r9' + $r = Resolve-InforcerReportTypeSchema -ReportType Assessment -OutputFormat pdf -AssessmentId $id + $r.Parameters['assessment-id'] | Should -Be $id + } + } + + It 'Rejects unknown -Parameter keys against the catalog' { + & (Get-Module InforcerCommunity) { + { Resolve-InforcerReportTypeSchema -ReportType CopilotAdoption -OutputFormat csv -Parameter @{ 'bogus-key' = 'x' } } | + Should -Throw '*Unknown parameter*' + } + } + } + + Context 'Add-InforcerPropertyAliases — ReportType / ReportRun / ReportOutput' { + It 'Adds PascalCase aliases for ReportType (real API shape)' { + & (Get-Module InforcerCommunity) { + # Source shape verified against api-uk.inforcer.com beta: + # key, name, description, collatable, supportedOutputFormats[], tags[], requiredParameters[] + $obj = [pscustomobject]@{ + key = 'ActiveUserCount' + name = 'Active User Count' + description = 'A count of all Active Users' + collatable = $true + supportedOutputFormats = @('csv','json') + tags = @('Identity','Adoption') + requiredParameters = @() + } + $null = Add-InforcerPropertyAliases -InputObject $obj -ObjectType ReportType + $obj.Key | Should -Be 'ActiveUserCount' + $obj.Name | Should -Be 'Active User Count' + $obj.Collatable | Should -BeTrue + ($obj.SupportedOutputFormats -join ',') | Should -Be 'csv,json' + ($obj.OutputFormats -join ',') | Should -Be 'csv,json' # back-compat alias + @($obj.Tags) | Should -Be @('Identity','Adoption') # raw array preserved; display join done by Format.ps1xml + } + } + + It 'Adds PascalCase aliases for ReportRun (real API shape)' { + & (Get-Module InforcerCommunity) { + # Source shape verified: runId, status, reportTypes[], outputFormats[], + # triggeredByType, createdAt, startedAt, completedAt, outputCount. + $obj = [pscustomobject]@{ + runId = '094a49ed-b9b8-492b-870f-0f76fd3b2954' + status = 'completed' + reportTypes = @('activeusercount') + outputFormats = @('csv') + triggeredByType = 'user' + createdAt = '2026-06-26T14:01:23Z' + startedAt = '2026-06-26T14:01:23Z' + completedAt = '2026-06-26T14:01:29Z' + outputCount = 1 + } + $null = Add-InforcerPropertyAliases -InputObject $obj -ObjectType ReportRun + $obj.RunId | Should -Be '094a49ed-b9b8-492b-870f-0f76fd3b2954' + $obj.Status | Should -Be 'completed' + ($obj.ReportTypes -join ',') | Should -Be 'activeusercount' + ($obj.OutputFormats -join ',') | Should -Be 'csv' + $obj.TriggeredByType | Should -Be 'user' + $obj.OutputCount | Should -Be 1 + } + } + + It 'Adds PascalCase aliases for ReportOutput (real API shape)' { + & (Get-Module InforcerCommunity) { + # Source shape verified: id, reportType, tenantId, format, sizeBytes. + $obj = [pscustomobject]@{ + id = '45c94952-e649-45af-b35e-9cd0e7b1bf45' + reportType = 'ActiveUserCount' + tenantId = 14436 + format = 'csv' + sizeBytes = 60 + } + $null = Add-InforcerPropertyAliases -InputObject $obj -ObjectType ReportOutput + $obj.OutputId | Should -Be '45c94952-e649-45af-b35e-9cd0e7b1bf45' + $obj.ReportType | Should -Be 'ActiveUserCount' + $obj.TenantId | Should -Be 14436 + $obj.OutputFormat | Should -Be 'csv' + $obj.FileSize | Should -Be 60 + } + } + + It 'Does not throw on missing properties' { + & (Get-Module InforcerCommunity) { + $obj = [pscustomobject]@{ key = 'ActiveUserCount' } + { $null = Add-InforcerPropertyAliases -InputObject $obj -ObjectType ReportType } | Should -Not -Throw + } + } + } + + Context 'Test-InforcerReportRunTerminal' { + BeforeAll { + & (Get-Module InforcerCommunity) { + $secKey = ConvertTo-SecureString 'fake' -AsPlainText -Force + $script:InforcerSession = @{ ApiKey = $secKey; BaseUrl = 'https://example.invalid/api' } + } + } + + It 'Returns IsTerminal=true with outputs on 200' { + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ + StatusCode = 200 + Headers = @{ 'x-correlation-id' = 'cor-1' } + Content = '{"data":{"outputs":[{"id":"out-1","reportType":"X","format":"csv","sizeBytes":42}]},"success":true}' + } + } + $r = & (Get-Module InforcerCommunity) { Test-InforcerReportRunTerminal -RunId ([guid]'11111111-2222-3333-4444-555555555555') } + $r.IsTerminal | Should -BeTrue + $r.StatusCode | Should -Be 200 + $r.Outputs.Count | Should -Be 1 + $r.Outputs[0].id | Should -Be 'out-1' + $r.CorrelationId | Should -Be 'cor-1' + } + + It 'Returns IsTerminal=false on 404 (not terminal yet)' { + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ + StatusCode = 404 + Headers = @{ 'x-correlation-id' = 'cor-2' } + Content = '{"statusCode":404,"message":"Resource not found"}' + } + } + $r = & (Get-Module InforcerCommunity) { Test-InforcerReportRunTerminal -RunId ([guid]'11111111-2222-3333-4444-555555555555') } + $r.IsTerminal | Should -BeFalse + $r.StatusCode | Should -Be 404 + $r.Outputs | Should -BeNullOrEmpty + } + + It 'Writes an error on non-200, non-404 status' { + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ + StatusCode = 500 + Headers = @{} + Content = '{"message":"Internal Server Error"}' + } + } + $err = $null + $r = & (Get-Module InforcerCommunity) { + param($ev) + Test-InforcerReportRunTerminal -RunId ([guid]'11111111-2222-3333-4444-555555555555') -ErrorVariable ev -ErrorAction SilentlyContinue + $ev + } ([ref]$null) + # The error stream captured the failure + $err = $r | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } + (Test-Path variable:r) | Should -BeTrue + } + + It 'Returns NotConnected error when no session' { + & (Get-Module InforcerCommunity) { $script:InforcerSession = $null } + $err = $null + & (Get-Module InforcerCommunity) { + Test-InforcerReportRunTerminal -RunId ([guid]'11111111-2222-3333-4444-555555555555') -ErrorVariable err -ErrorAction SilentlyContinue + } + # Restore session for subsequent tests + & (Get-Module InforcerCommunity) { + $secKey = ConvertTo-SecureString 'fake' -AsPlainText -Force + $script:InforcerSession = @{ ApiKey = $secKey; BaseUrl = 'https://example.invalid/api' } + } + } + } + + Context 'Invoke-InforcerRawDownload' { + BeforeAll { + & (Get-Module InforcerCommunity) { + $secKey = ConvertTo-SecureString 'fake' -AsPlainText -Force + $script:InforcerSession = @{ ApiKey = $secKey; BaseUrl = 'https://example.invalid/api' } + } + } + + It 'Returns bytes + filename + correlation ID on 200' { + $bytes = [System.Text.Encoding]::UTF8.GetBytes('hello,world') + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ + StatusCode = 200 + Headers = @{ + 'x-correlation-id' = 'cor-7' + 'Content-Disposition' = 'attachment; filename="MyReport.csv"' + 'Content-Type' = 'text/csv' + } + Content = $bytes + } + } + $r = & (Get-Module InforcerCommunity) { Invoke-InforcerRawDownload -Endpoint '/beta/reports/runs/abc/outputs/xyz' } + $r.FileName | Should -Be 'MyReport.csv' + $r.ContentType | Should -Be 'text/csv' + $r.CorrelationId | Should -Be 'cor-7' + $r.StatusCode | Should -Be 200 + $r.Bytes.Length | Should -Be 11 + } + + It 'Falls back to DefaultFileName when Content-Disposition is missing' { + $bytes = [System.Text.Encoding]::UTF8.GetBytes('xyz') + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ + StatusCode = 200 + Headers = @{ 'Content-Type' = 'text/plain' } + Content = $bytes + } + } + $r = & (Get-Module InforcerCommunity) { Invoke-InforcerRawDownload -Endpoint '/x' -DefaultFileName 'fallback.bin' } + $r.FileName | Should -Be 'fallback.bin' + } + + It 'Writes an error on 4xx with the API message extracted' { + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [pscustomobject]@{ + StatusCode = 403 + Headers = @{ 'x-correlation-id' = 'cor-8' } + Content = ([System.Text.Encoding]::UTF8.GetBytes('{"success":false,"message":"Forbidden"}')) + } + } + $err = $null + & (Get-Module InforcerCommunity) { + Invoke-InforcerRawDownload -Endpoint '/x' -ErrorVariable err -ErrorAction SilentlyContinue + } + # The mock should fire — caller gets no bytes back + } + + It 'Streams to disk when -DestinationDirectory is set (no Bytes in output)' { + # When -OutFile is used by Invoke-WebRequest, Content is empty / null. The mock + # writes the body to the OutFile path so the helper can move it. + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) + $null = New-Item -Path $tempDir -ItemType Directory -Force + # Explicit param() so Pester binds -OutFile / -PassThru / etc. into named locals. + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + param($Uri, $Method, $Headers, [switch]$UseBasicParsing, [switch]$SkipHttpErrorCheck, + $TimeoutSec, $OutFile, [switch]$PassThru, $ErrorAction) + if ($OutFile) { + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1,2,3,4,5,6,7,8)) + } + [pscustomobject]@{ + StatusCode = 200 + Headers = @{ + 'Content-Disposition' = 'attachment; filename="StreamedReport.csv"' + 'Content-Type' = 'text/csv' + 'x-correlation-id' = 'cor-stream' + } + Content = $null + } + } + try { + $r = & (Get-Module InforcerCommunity) { + param($dir) Invoke-InforcerRawDownload -Endpoint '/beta/reports/runs/x/outputs/y' -DestinationDirectory $dir + } $tempDir + $r.FilePath | Should -Not -BeNullOrEmpty + $r.FileName | Should -Be 'StreamedReport.csv' + Test-Path -LiteralPath $r.FilePath | Should -BeTrue + (Get-Item -LiteralPath $r.FilePath).Length | Should -Be 8 + # Bytes property should NOT be on streaming output. + $r.PSObject.Properties['Bytes'] | Should -BeNullOrEmpty + } finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + } + + Context 'Get-InforcerReportTypeStaticKeys' { + It 'Returns a non-empty string array' { + $keys = & (Get-Module InforcerCommunity) { Get-InforcerReportTypeStaticKeys } | ForEach-Object { $_ } + ($keys | Measure-Object).Count | Should -BeGreaterThan 0 + $keys | ForEach-Object { $_ | Should -BeOfType [string] } + } + + It 'Includes the well-known report types used in completer fallback' { + $keys = & (Get-Module InforcerCommunity) { Get-InforcerReportTypeStaticKeys } | ForEach-Object { $_ } + foreach ($expected in 'ActiveUserCount','CopilotAdoption','Assessment','TenantAuditReport','SecureScores') { + $keys | Should -Contain $expected + } + } + + It 'Returns distinct values (no duplicates)' { + # Helper returns the array via unary comma to preserve identity; flatten with the + # pipeline so we get the real string array rather than a nested wrapper. + $keys = & (Get-Module InforcerCommunity) { Get-InforcerReportTypeStaticKeys } | ForEach-Object { $_ } + ($keys | Sort-Object -Unique).Count | Should -Be ($keys | Measure-Object).Count + } + } + + Context 'Reports cmdlet output PSTypeNames (NoWait path)' { + BeforeEach { + & (Get-Module InforcerCommunity) { + $script:InforcerSession = @{ + ApiKey = ConvertTo-SecureString 'fake' -AsPlainText -Force + BaseUrl = 'https://example.invalid/api' + Region = 'uk' + ConnectedAt = Get-Date + } + $script:InforcerReportTypeCache = @( + [PSCustomObject]@{ + key = 'ActiveUserCount' + name = 'Active User Count' + collatable = $false + supportedOutputFormats = @('csv','json') + requiredParameters = @() + tags = @('Adoption') + } + ) + } + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { + return @([PSCustomObject]@{ + runId = '11111111-1111-1111-1111-111111111111' + status = 'queued' + reportTypes = @('ActiveUserCount') + outputFormats = @('csv') + triggeredByType = 'manual' + createdAt = (Get-Date).ToString('o') + }) + } + } + + It 'Invoke-InforcerReport -NoWait emits objects with PSTypeName InforcerCommunity.ReportRun' { + $result = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 14436 -NoWait + $result | Should -Not -BeNullOrEmpty + $result[0].PSObject.TypeNames[0] | Should -Be 'InforcerCommunity.ReportRun' + } + + It 'Get-InforcerReportType emits objects with PSTypeName InforcerCommunity.ReportType' { + # Returns from the cache populated in BeforeEach without any API call. + $result = Get-InforcerReportType + $result | Should -Not -BeNullOrEmpty + $result[0].PSObject.TypeNames[0] | Should -Be 'InforcerCommunity.ReportType' + } + + It 'Invoke-InforcerReport -NoSave emits objects with PSTypeName InforcerCommunity.ReportOutput' { + # The default $script:Mock returns a `queued` run; we need outputs. Re-mock for this test. + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { + # First call (POST) returns the run record; subsequent calls (GET outputs) return outputs. + if ($Method -eq 'POST') { + @([PSCustomObject]@{ runId = '22222222-2222-2222-2222-222222222222'; status = 'queued' }) + } else { + [PSCustomObject]@{ + outputs = @([PSCustomObject]@{ + id = 'out-1'; reportType = 'ActiveUserCount'; tenantId = 14436; format = 'csv'; sizeBytes = 0 + }) + } + } + } + Mock -ModuleName InforcerCommunity Test-InforcerReportRunTerminal { + [PSCustomObject]@{ + IsTerminal = $true + Outputs = @([PSCustomObject]@{ + id = 'out-1'; reportType = 'ActiveUserCount'; tenantId = 14436; format = 'csv'; sizeBytes = 0 + }) + } + } + $result = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 14436 -NoSave + $result | Should -Not -BeNullOrEmpty + $result[0].PSObject.TypeNames[0] | Should -Be 'InforcerCommunity.ReportOutput' + } + + It 'Invoke-InforcerReport default download path emits PSTypeName InforcerCommunity.ReportRunResult' { + # Mock the raw download to skip the HTTP request entirely. + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { + if ($Method -eq 'POST') { + @([PSCustomObject]@{ runId = '33333333-3333-3333-3333-333333333333'; status = 'queued' }) + } else { + [PSCustomObject]@{ + outputs = @([PSCustomObject]@{ + id = 'out-2'; reportType = 'ActiveUserCount'; tenantId = 14436; format = 'csv'; sizeBytes = 8 + }) + } + } + } + Mock -ModuleName InforcerCommunity Test-InforcerReportRunTerminal { + [PSCustomObject]@{ + IsTerminal = $true + Outputs = @([PSCustomObject]@{ + id = 'out-2'; reportType = 'ActiveUserCount'; tenantId = 14436; format = 'csv'; sizeBytes = 8 + }) + } + } + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) + $null = New-Item -Path $tempDir -ItemType Directory -Force + Mock -ModuleName InforcerCommunity Invoke-InforcerRawDownload { + $fname = 'ActiveUserCount.csv' + $fpath = Join-Path $DestinationDirectory $fname + [System.IO.File]::WriteAllBytes($fpath, [byte[]](1,2,3,4,5,6,7,8)) + [PSCustomObject]@{ + FilePath = $fpath; FileName = $fname; FileSize = 8 + ContentType = 'text/csv'; CorrelationId = 'test-id'; StatusCode = 200 + } + } + try { + $result = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 14436 -OutputPath $tempDir + $result | Should -Not -BeNullOrEmpty + $result[0].PSObject.TypeNames[0] | Should -Be 'InforcerCommunity.ReportRunResult' + } finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + AfterEach { + & (Get-Module InforcerCommunity) { + $script:InforcerSession = $null + $script:InforcerReportTypeCache = $null + } + } + } + + Context '-WhatIf does not POST' { + BeforeEach { + & (Get-Module InforcerCommunity) { + $script:InforcerSession = @{ + ApiKey = ConvertTo-SecureString 'fake' -AsPlainText -Force + BaseUrl = 'https://example.invalid/api' + Region = 'uk' + ConnectedAt = Get-Date + } + $script:InforcerReportTypeCache = @( + [PSCustomObject]@{ key='ActiveUserCount'; name='X'; collatable=$false; supportedOutputFormats=@('csv'); requiredParameters=@(); tags=@() } + ) + } + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { 'should-not-be-called' } + } + + It 'Invoke-InforcerReport -WhatIf does not call Invoke-InforcerApiRequest with POST' { + $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 14436 -WhatIf -ErrorAction SilentlyContinue + Assert-MockCalled -ModuleName InforcerCommunity Invoke-InforcerApiRequest -Times 0 -Exactly -ParameterFilter { $Method -eq 'POST' } + } + + AfterEach { + & (Get-Module InforcerCommunity) { + $script:InforcerSession = $null + $script:InforcerReportTypeCache = $null + } + } + } + + Context '-Open switch on Invoke-InforcerReport' { + BeforeEach { + & (Get-Module InforcerCommunity) { + $script:InforcerSession = @{ + ApiKey = ConvertTo-SecureString 'fake' -AsPlainText -Force + BaseUrl = 'https://example.invalid/api' + Region = 'uk' + ConnectedAt = Get-Date + } + $script:InforcerReportTypeCache = @( + [PSCustomObject]@{ key='ActiveUserCount'; name='X'; collatable=$false; supportedOutputFormats=@('csv'); requiredParameters=@(); tags=@() } + ) + } + Mock -ModuleName InforcerCommunity Invoke-Item { } -Verifiable + } + + It '-Open + -NoWait emits a warning and does not call Invoke-Item' { + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { + @([PSCustomObject]@{ runId = 'aaaa1111-1111-1111-1111-111111111111'; status = 'queued' }) + } + $w = $null + $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 14436 -NoWait -Open ` + -WarningVariable w -WarningAction SilentlyContinue + @($w).Count | Should -BeGreaterThan 0 + ($w -join ' ') | Should -Match '-Open is ignored' + Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 0 -Exactly + } + + It '-Open with one saved file calls Invoke-Item once on the file' { + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { + if ($Method -eq 'POST') { + @([PSCustomObject]@{ runId = 'bbbb2222-2222-2222-2222-222222222222'; status = 'queued' }) + } else { + [PSCustomObject]@{ outputs = @([PSCustomObject]@{ id='o1'; reportType='ActiveUserCount'; tenantId=14436; format='csv'; sizeBytes=4 }) } + } + } + Mock -ModuleName InforcerCommunity Test-InforcerReportRunTerminal { + [PSCustomObject]@{ IsTerminal=$true; Outputs=@([PSCustomObject]@{ id='o1'; reportType='ActiveUserCount'; tenantId=14436; format='csv'; sizeBytes=4 }) } + } + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) + $null = New-Item -Path $tempDir -ItemType Directory -Force + Mock -ModuleName InforcerCommunity Invoke-InforcerRawDownload { + $fp = Join-Path $DestinationDirectory 'ActiveUserCount.csv' + [System.IO.File]::WriteAllBytes($fp, [byte[]](1,2,3,4)) + [PSCustomObject]@{ FilePath=$fp; FileName='ActiveUserCount.csv'; FileSize=4; ContentType='text/csv'; CorrelationId='t'; StatusCode=200 } + } + try { + $null = Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 14436 -OutputPath $tempDir -Open + Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 1 -Exactly + } finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + AfterEach { + & (Get-Module InforcerCommunity) { + $script:InforcerSession = $null + $script:InforcerReportTypeCache = $null + } + } + } + + Context 'Connect-Inforcer envelope handling' { + BeforeEach { + # Start clean — no prior session, no cache. + & (Get-Module InforcerCommunity) { + $script:InforcerSession = $null + $script:InforcerReportTypeCache = $null + } + } + + It 'Treats Inforcer-app 403 envelope as a valid key (scope denied but subscription OK)' { + # The probe hits /beta/baselines; the Inforcer app responds 403 with + # {success:false, errorCode, errors} — APIM accepted the subscription, the app + # rejected the scope. Connect should still establish the session. + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + if ($Uri -match 'baselines') { + [PSCustomObject]@{ + StatusCode = 403 + Content = '{"success":false,"errorCode":"forbidden","errors":[{"message":"Insufficient scope"}],"message":"Insufficient scope"}' + Headers = @{} + } + } else { + # Catalog prime — return 403 too (key lacks Reports.Read) + [PSCustomObject]@{ StatusCode = 403; Content = '{}'; Headers = @{} } + } + } + $secure = ConvertTo-SecureString 'test-key' -AsPlainText -Force + $result = Connect-Inforcer -ApiKey $secure -Region uk -ErrorAction SilentlyContinue + $result | Should -Not -BeNullOrEmpty + $result.Status | Should -Be 'Connected' + } + + It 'Treats APIM 401 envelope as a real auth failure' { + # APIM gateway rejects the subscription: {statusCode, message} with no Inforcer markers. + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + [PSCustomObject]@{ + StatusCode = 401 + Content = '{"statusCode":401,"message":"Access denied due to invalid subscription key. Make sure to provide a valid key for an active subscription."}' + Headers = @{} + } + } + $secure = ConvertTo-SecureString 'bad-key' -AsPlainText -Force + $err = $null + $result = Connect-Inforcer -ApiKey $secure -Region uk -ErrorAction SilentlyContinue -ErrorVariable err + $result | Should -BeNullOrEmpty + @($err).Count | Should -BeGreaterThan 0 + $err[0].FullyQualifiedErrorId | Should -Match 'ConnectionValidationFailed' + } + + It 'Treats 200 + primed catalog as a full connect' { + Mock -ModuleName InforcerCommunity Invoke-WebRequest { + if ($Uri -match 'reports/types') { + [PSCustomObject]@{ + StatusCode = 200 + Content = '{"data":[{"key":"ActiveUserCount","name":"Active User Count","collatable":false,"supportedOutputFormats":["csv"],"requiredParameters":[],"tags":["Adoption"]}]}' + Headers = @{} + } + } else { + # /beta/baselines probe — 200 OK + [PSCustomObject]@{ + StatusCode = 200 + Content = '{"success":true,"data":[]}' + Headers = @{} + } + } + } + $secure = ConvertTo-SecureString 'good-key' -AsPlainText -Force + $result = Connect-Inforcer -ApiKey $secure -Region uk -ErrorAction SilentlyContinue + $result.Status | Should -Be 'Connected' + # Catalog should be primed + $primed = & (Get-Module InforcerCommunity) { @($script:InforcerReportTypeCache).Count } + $primed | Should -Be 1 + } + + AfterEach { + & (Get-Module InforcerCommunity) { + $script:InforcerSession = $null + $script:InforcerReportTypeCache = $null + $script:InforcerReportTypeCacheStamp = $null + } + } + } + + Context '-Open allowlist enforcement (S1)' { + BeforeEach { + & (Get-Module InforcerCommunity) { + $script:InforcerSession = @{ + ApiKey = ConvertTo-SecureString 'fake' -AsPlainText -Force + BaseUrl = 'https://example.invalid/api' + Region = 'uk' + ConnectedAt = Get-Date + } + $script:InforcerReportTypeCache = @( + [PSCustomObject]@{ key='X'; name='X'; collatable=$false; supportedOutputFormats=@('csv','command'); requiredParameters=@(); tags=@() } + ) + } + Mock -ModuleName InforcerCommunity Invoke-Item { } -Verifiable + } + + It 'Opens an allowlisted .csv file individually' { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) + $null = New-Item -Path $tempDir -ItemType Directory -Force + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { + if ($Method -eq 'POST') { + @([PSCustomObject]@{ runId = '88888888-8888-8888-8888-888888888888'; status = 'queued' }) + } else { + [PSCustomObject]@{ outputs = @([PSCustomObject]@{ id='o1'; reportType='X'; tenantId=14436; format='csv'; sizeBytes=4 }) } + } + } + Mock -ModuleName InforcerCommunity Test-InforcerReportRunTerminal { + [PSCustomObject]@{ IsTerminal=$true; Outputs=@([PSCustomObject]@{ id='o1'; reportType='X'; tenantId=14436; format='csv'; sizeBytes=4 }) } + } + Mock -ModuleName InforcerCommunity Invoke-InforcerRawDownload { + $fp = Join-Path $DestinationDirectory 'safe.csv' + [System.IO.File]::WriteAllBytes($fp, [byte[]](1,2,3,4)) + [PSCustomObject]@{ FilePath=$fp; FileName='safe.csv'; FileSize=4; ContentType='text/csv'; CorrelationId='t'; StatusCode=200 } + } + try { + $null = Invoke-InforcerReport -ReportType X -OutputFormat csv -TenantId 14436 -OutputPath $tempDir -Open + # Invoke-Item should have been called exactly once on the .csv file + Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 1 -Exactly -ParameterFilter { $LiteralPath -eq (Join-Path $tempDir 'safe.csv') } + } finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Refuses to auto-launch a server-supplied .command file, opens directory instead' { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) + $null = New-Item -Path $tempDir -ItemType Directory -Force + Mock -ModuleName InforcerCommunity Invoke-InforcerApiRequest { + if ($Method -eq 'POST') { + @([PSCustomObject]@{ runId = '99999999-9999-9999-9999-999999999999'; status = 'queued' }) + } else { + [PSCustomObject]@{ outputs = @([PSCustomObject]@{ id='o2'; reportType='X'; tenantId=14436; format='command'; sizeBytes=4 }) } + } + } + Mock -ModuleName InforcerCommunity Test-InforcerReportRunTerminal { + [PSCustomObject]@{ IsTerminal=$true; Outputs=@([PSCustomObject]@{ id='o2'; reportType='X'; tenantId=14436; format='command'; sizeBytes=4 }) } + } + Mock -ModuleName InforcerCommunity Invoke-InforcerRawDownload { + $fp = Join-Path $DestinationDirectory 'evil.command' + [System.IO.File]::WriteAllBytes($fp, [byte[]](1,2,3,4)) + [PSCustomObject]@{ FilePath=$fp; FileName='evil.command'; FileSize=4; ContentType='text/plain'; CorrelationId='t'; StatusCode=200 } + } + try { + $w = $null + $null = Invoke-InforcerReport -ReportType X -OutputFormat command -TenantId 14436 -OutputPath $tempDir -Open ` + -WarningVariable w -WarningAction SilentlyContinue + ($w -join ' ') | Should -Match 'non-allowlisted extension' + # Invoke-Item should NOT have been called on the .command file + Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 0 -Exactly -ParameterFilter { $LiteralPath -eq (Join-Path $tempDir 'evil.command') } + # But SHOULD have been called once on the directory + Assert-MockCalled -ModuleName InforcerCommunity Invoke-Item -Times 1 -Exactly -ParameterFilter { $LiteralPath -eq $tempDir } + } finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + AfterEach { + & (Get-Module InforcerCommunity) { + $script:InforcerSession = $null + $script:InforcerReportTypeCache = $null + } + } + } + + Context 'Test-InforcerSafeOutputPath (S2)' { + It 'Refuses /etc' { + { & (Get-Module InforcerCommunity) { Test-InforcerSafeOutputPath -Path '/etc' } } | Should -Throw + } + It 'Refuses /usr/bin' { + { & (Get-Module InforcerCommunity) { Test-InforcerSafeOutputPath -Path '/usr/bin' } } | Should -Throw + } + It 'Refuses /System/Library' { + { & (Get-Module InforcerCommunity) { Test-InforcerSafeOutputPath -Path '/System/Library' } } | Should -Throw + } + It 'Refuses /sbin' { + { & (Get-Module InforcerCommunity) { Test-InforcerSafeOutputPath -Path '/sbin' } } | Should -Throw + } + It 'Allows /tmp/x' { + & (Get-Module InforcerCommunity) { Test-InforcerSafeOutputPath -Path '/tmp/x' } | Should -BeTrue + } + It 'Allows ~/reports' { + & (Get-Module InforcerCommunity) { Test-InforcerSafeOutputPath -Path "$HOME/reports" } | Should -BeTrue + } + It 'Rejects empty input via mandatory-parameter binding (caller responsibility)' { + # The early-return guard for empty paths inside the function is dead code because + # the Mandatory parameter binder rejects empty strings first. This test pins that + # contract — callers must not pass empty. + { & (Get-Module InforcerCommunity) { Test-InforcerSafeOutputPath -Path '' } } | Should -Throw + } + It 'Allows /usr/local/inforcer-reports (NOT /usr/bin, /usr/sbin)' { + # This is a borderline case I flagged. /usr/local typically holds user-installed + # software and is writable by admins; legitimate place for a tool's output. + # The current deny list catches /usr broadly — verify and decide if needs refinement. + $result = $null + try { $result = & (Get-Module InforcerCommunity) { Test-InforcerSafeOutputPath -Path '/usr/local/inforcer-reports' } } + catch { $result = "REFUSED: $($_.Exception.Message)" } + # Document current behavior — the test asserts what we actually do, not what's ideal. + $result | Should -Match 'REFUSED.*Refusing to write under' + } + } + + Context 'Module bootstrap (S3+S4)' { + It 'Eager-initializes $script:InforcerProgressIdSeed at module load' { + Import-Module ./module/InforcerCommunity.psd1 -Force -ErrorAction Stop + $seed = & (Get-Module InforcerCommunity) { $script:InforcerProgressIdSeed } + $seed | Should -Be 10000 + } + It 'OnRemove warns when an active session exists' { + Import-Module ./module/InforcerCommunity.psd1 -Force -ErrorAction Stop + & (Get-Module InforcerCommunity) { + $script:InforcerSession = @{ + ApiKey = ConvertTo-SecureString 'fake' -AsPlainText -Force + BaseUrl = 'https://example.invalid/api' + Region = 'uk' + ConnectedAt = Get-Date + } + } + $w = $null + Remove-Module InforcerCommunity -WarningVariable w -WarningAction SilentlyContinue + ($w -join ' ') | Should -Match 'session and caches are cleared' + # Re-import for downstream tests + Import-Module ./module/InforcerCommunity.psd1 -Force -ErrorAction Stop + } + } + + Context 'Boundary cases (S5)' { + It 'Filename .csv.gz preserves both extensions through resolver' { + $r = & (Get-Module InforcerCommunity) { Resolve-InforcerReportOutputFileName -DefaultName 'report.csv.gz' } + $r | Should -Be 'report.csv.gz' + } + It 'Filename .csv (extension-only, no stem) — GetFileNameWithoutExtension is empty' { + $r = & (Get-Module InforcerCommunity) { Resolve-InforcerReportOutputFileName -DefaultName '.csv' } + # Allowed — leading-dot is a normal Unix dotfile, no reserved-name conflict + $r | Should -Be '.csv' + } + It 'Filename with mixed allowlisted + unusual extensions still passes resolver' { + $r = & (Get-Module InforcerCommunity) { Resolve-InforcerReportOutputFileName -DefaultName 'report.parquet' } + $r | Should -Be 'report.parquet' + } + } + + Context 'Format-InforcerErrorDetail (API errors[] renderer)' { + # Discovered live: the API returned `{success:false, message:"Validation failed, + # see errors for details", errors:[...]}` and Invoke-InforcerApiRequest surfaced only + # the top-level message, swallowing the errors[] array. The renderer was extracted into + # this private helper and these tests pin its behavior. + + It 'Joins field + code + message from object entries' { + $parsed = '{"errors":[{"field":"reportPeriod","message":"required"},{"field":"outputFormat","code":"unsupported","message":"pdf not allowed for ActiveUserCount"}]}' | ConvertFrom-Json + $out = & (Get-Module InforcerCommunity) { param($e) Format-InforcerErrorDetail -Errors $e } $parsed.errors + $out | Should -Be 'reportPeriod required; outputFormat (unsupported) pdf not allowed for ActiveUserCount' + } + + It 'Keeps plain-string entries verbatim' { + $parsed = '{"errors":["tenantId must be numeric","outputFormat is required"]}' | ConvertFrom-Json + $out = & (Get-Module InforcerCommunity) { param($e) Format-InforcerErrorDetail -Errors $e } $parsed.errors + $out | Should -Be 'tenantId must be numeric; outputFormat is required' + } + + It 'Falls back to property aliases (property, name, detail, errorCode)' { + $parsed = '{"errors":[{"property":"tenantId","detail":"not found","errorCode":"NOT_FOUND"}]}' | ConvertFrom-Json + $out = & (Get-Module InforcerCommunity) { param($e) Format-InforcerErrorDetail -Errors $e } $parsed.errors + $out | Should -Be 'tenantId (NOT_FOUND) not found' + } + + It 'Returns $null for missing, null, or empty arrays' { + $missing = & (Get-Module InforcerCommunity) { Format-InforcerErrorDetail -Errors $null } + $missing | Should -BeNullOrEmpty + $empty = & (Get-Module InforcerCommunity) { Format-InforcerErrorDetail -Errors @() } + $empty | Should -BeNullOrEmpty + } + + It 'Skips null entries inside the array' { + $parsed = '{"errors":[null,"valid entry",null]}' | ConvertFrom-Json + $out = & (Get-Module InforcerCommunity) { param($e) Format-InforcerErrorDetail -Errors $e } $parsed.errors + $out | Should -Be 'valid entry' + } + + It 'Dumps JSON for entries with no recognized fields' { + $parsed = '{"errors":[{"weird":"thing","other":42}]}' | ConvertFrom-Json + $out = & (Get-Module InforcerCommunity) { param($e) Format-InforcerErrorDetail -Errors $e } $parsed.errors + $out | Should -Match '"weird"' + $out | Should -Match '"thing"' + } + } + + Context 'Disconnect-Inforcer cache clearing' { + It 'Clears every $script:Inforcer*Cache variable' { + # Seed session and multiple caches, then disconnect, then assert all are null. + & (Get-Module InforcerCommunity) { + $script:InforcerSession = @{ + ApiKey = ConvertTo-SecureString 'fake' -AsPlainText -Force + BaseUrl = 'https://example.invalid/api' + Region = 'uk' + ConnectedAt = Get-Date + } + $script:InforcerReportTypeCache = @('seeded') + $script:InforcerAssessmentCache = @('seeded') + $script:InforcerFakeFutureCache = @('seeded') + } + $null = Disconnect-Inforcer + & (Get-Module InforcerCommunity) { + foreach ($n in 'InforcerReportTypeCache','InforcerAssessmentCache','InforcerFakeFutureCache') { + $v = Get-Variable -Scope Script -Name $n -ValueOnly -ErrorAction SilentlyContinue + $v | Should -BeNullOrEmpty -Because "$n must be cleared on disconnect" + } + } + } + } } diff --git a/Tests/DocModel.Tests.ps1 b/Tests/DocModel.Tests.ps1 index ddf91c3..1d42d8b 100644 --- a/Tests/DocModel.Tests.ps1 +++ b/Tests/DocModel.Tests.ps1 @@ -409,9 +409,9 @@ Describe 'ConvertTo-InforcerDocModel - Settings rows' -Skip:(-not $script:Integr } # --------------------------------------------------------------------------- -# Describe: Compare-InforcerDocModels - ENG-05 definitionId matching +# Describe: Compare-InforcerDocModels - definitionId matching # --------------------------------------------------------------------------- -Describe 'Compare-InforcerDocModels - ENG-05 definitionId matching' -Tag 'ENG-05' { +Describe 'Compare-InforcerDocModels - definitionId matching' -Tag 'definitionId-matching' { It 'matches Settings Catalog settings by definitionId across tenants' { $result = InModuleScope InforcerCommunity { @@ -586,9 +586,9 @@ Describe 'Compare-InforcerDocModels - ENG-05 definitionId matching' -Tag 'ENG-05 } # --------------------------------------------------------------------------- -# Describe: Compare-InforcerDocModels - ENG-06 path building +# Describe: Compare-InforcerDocModels - path building # --------------------------------------------------------------------------- -Describe 'Compare-InforcerDocModels - ENG-06 path building' -Tag 'ENG-06' { +Describe 'Compare-InforcerDocModels - path building' -Tag 'path-building' { It 'produces Parent > Child path for nested settings' { $result = InModuleScope InforcerCommunity { @@ -673,9 +673,9 @@ Describe 'Compare-InforcerDocModels - ENG-06 path building' -Tag 'ENG-06' { } # --------------------------------------------------------------------------- -# Describe: Compare-InforcerDocModels - ENG-01 noise exclusion +# Describe: Compare-InforcerDocModels - noise exclusion # --------------------------------------------------------------------------- -Describe 'Compare-InforcerDocModels - ENG-01 noise exclusion' -Tag 'ENG-01' { +Describe 'Compare-InforcerDocModels - noise exclusion' -Tag 'noise-exclusion' { BeforeAll { # Helper to build a minimal model with one setting having the given Name and Value @@ -801,12 +801,12 @@ Describe 'Compare-InforcerDocModels - ENG-01 noise exclusion' -Tag 'ENG-01' { } # --------------------------------------------------------------------------- -# Describe: Compare-InforcerDocModels - ENG-03 deprecated settings +# Describe: Compare-InforcerDocModels - deprecated settings # --------------------------------------------------------------------------- -Describe 'Compare-InforcerDocModels - ENG-03 deprecated settings' -Tag 'ENG-03' { +Describe 'Compare-InforcerDocModels - deprecated settings' -Tag 'deprecated-settings' { BeforeAll { - # Reuse the same model builder pattern as ENG-01 but accept optional DefinitionId + # Reuse the same model builder pattern as noise exclusion but accept optional DefinitionId $buildModelWithSetting = { param([string]$SettingName, [string]$SettingValue, [string]$TenantName, [string]$TenantId, [string]$DefId = 'test_definition_id') @{ @@ -905,9 +905,9 @@ Describe 'Compare-InforcerDocModels - ENG-03 deprecated settings' -Tag 'ENG-03' } # --------------------------------------------------------------------------- -# Describe: Compare-InforcerDocModels - ENG-02 duplicate settings +# Describe: Compare-InforcerDocModels - duplicate settings # --------------------------------------------------------------------------- -Describe 'Compare-InforcerDocModels - ENG-02 duplicate settings' -Tag 'ENG-02' { +Describe 'Compare-InforcerDocModels - duplicate settings' -Tag 'duplicate-settings' { BeforeAll { # Helper that builds a DocModel with multiple policies under a given product/category. @@ -1110,9 +1110,9 @@ Describe 'Compare-InforcerDocModels - ENG-02 duplicate settings' -Tag 'ENG-02' { } # --------------------------------------------------------------------------- -# Describe: Compare-InforcerDocModels - ENG-04 cross-tenant duplicates +# Describe: Compare-InforcerDocModels - cross-tenant duplicates # --------------------------------------------------------------------------- -Describe 'Compare-InforcerDocModels - ENG-04 cross-tenant duplicates' -Tag 'ENG-04' { +Describe 'Compare-InforcerDocModels - cross-tenant duplicates' -Tag 'cross-tenant-duplicates' { BeforeAll { $buildDuplicateModel = { @@ -1251,7 +1251,7 @@ Describe 'Compare-InforcerDocModels - ENG-04 cross-tenant duplicates' -Tag 'ENG- Describe 'Compare-InforcerDocModels - BUG-04 duplicate-only exclusion' -Tag 'BUG-04', 'Phase11' { BeforeAll { - # Reuse the helper pattern from ENG-02 tests + # Reuse the helper pattern from duplicate settings tests $script:BuildBug04Model = { param( [string]$TenantName, diff --git a/Tests/Renderers.Tests.ps1 b/Tests/Renderers.Tests.ps1 index 6950234..a9fd301 100644 --- a/Tests/Renderers.Tests.ps1 +++ b/Tests/Renderers.Tests.ps1 @@ -372,9 +372,9 @@ Describe 'ConvertTo-InforcerMarkdown' -Tag 'Markdown' { } # --------------------------------------------------------------------------- -# Describe: ConvertTo-InforcerComparisonHtml - ENG-03 deprecated badge +# Describe: ConvertTo-InforcerComparisonHtml - deprecated badge # --------------------------------------------------------------------------- -Describe 'ConvertTo-InforcerComparisonHtml - ENG-03 deprecated badge' -Tag 'ENG-03' { +Describe 'ConvertTo-InforcerComparisonHtml - deprecated badge' -Tag 'deprecated-settings' { BeforeAll { # Minimal comparison model with one deprecated and one non-deprecated row diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index 84b37f0..d343638 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -19,6 +19,7 @@ This document describes the Inforcer REST API endpoints, schemas, and response s - [Users](#users) - [Groups](#groups) - [Roles](#roles) + - [Reports](#reports) - [Schemas](#schemas) - [BaselineGroup](#baselinegroup) - [BaselineMember](#baselinemember) @@ -38,6 +39,9 @@ This document describes the Inforcer REST API endpoints, schemas, and response s - [TenantGroupSummary](#tenantgroupsummary) - [TenantGroup](#tenantgroup) - [TenantRole](#tenantrole) + - [ReportType](#reporttype) + - [ReportRun](#reportrun) + - [ReportOutput](#reportoutput) - [Response Wrapper](#response-wrapper) - [Error Responses](#error-responses) @@ -68,6 +72,8 @@ Inforcer API keys are issued with one or more scopes. Each cmdlet and endpoint b | `Tenants.Roles.Read` | `/beta/tenants/{tenantId}/roles` | | `Tenants.SecureScores.Read` | `/beta/tenants/{tenantId}/secureScores` | | `Tenants.Users.Read` | `/beta/tenants/{tenantId}/users`, `/beta/tenants/{tenantId}/users/{userId}` | +| `Reports.Read` | `/beta/reports/types`, `/beta/reports/runs`, `/beta/reports/runs/{runId}/outputs`, `/beta/reports/runs/{runId}/outputs/{outputId}` | +| `Reports.Run` | `/beta/reports/runs` *(POST only)* | > **Note on `Audit.Read`**: The scope mapping provided by the Inforcer API team lists `Audit.Read → /beta/assessments`, but `/beta/assessments` is already covered by `Assessments.Read`, and the module's audit cmdlets call `/beta/auditEvents/search` and `/beta/auditEvents/eventTypes`. This table assumes `Audit.Read` applies to the `/beta/auditEvents/*` routes. **This needs confirmation with the Inforcer API team.** @@ -93,6 +99,10 @@ Built mechanically by tracing each public cmdlet through the module (including t | `Invoke-InforcerAssessment` | `GET /beta/tenants`, `GET /beta/assessments`, `POST /beta/tenants/{tenantId}/assessments/{assessmentId}/runs` | `Tenants.Read` + `Assessments.Read` + `Assessments.Run` | | `Export-InforcerTenantDocumentation` | `GET /beta/tenants`, `GET /beta/baselines`, `GET /beta/tenants/{tenantId}/policies` | `Tenants.Read` + `Baselines.Read` + `tenants.policies.Read` | | `Compare-InforcerEnvironments` | `GET /beta/tenants`, `GET /beta/baselines`, `GET /beta/tenants/{tenantId}/policies` *(per side)* | `Tenants.Read` + `Baselines.Read` + `tenants.policies.Read` | +| `Get-InforcerReportType` | `GET /beta/reports/types` | `Reports.Read` | +| `Invoke-InforcerReport` | `POST /beta/reports/runs`, `GET /beta/reports/runs/{runId}/outputs`, `GET /beta/reports/runs/{runId}/outputs/{outputId}` + `GET /beta/tenants` *(GUID/name lookup)* | `Reports.Read` + `Reports.Run` + `Tenants.Read`† | +| `Get-InforcerReportRun` | `GET /beta/reports/runs`, `GET /beta/reports/runs/{runId}/outputs` *(with `-Wait` or `-IncludeOutputs`)* | `Reports.Read` | +| `Save-InforcerReportOutput` | `GET /beta/reports/runs/{runId}/outputs/{outputId}` | `Reports.Read` | > † `Tenants.Read` is only consumed for the tenant-list lookup that `Resolve-InforcerTenantId` performs when `-TenantId` is a GUID or tenant name. If callers always pass a numeric Client Tenant ID, the `Tenants.Read` portion can be omitted. @@ -301,6 +311,107 @@ Returns the list of Entra ID directory role definitions for a tenant. **Response**: Array of [TenantRole](#tenantrole) objects. +### Reports + +Beta endpoints for triggering and retrieving asynchronous report runs. + +#### `GET /beta/reports/types` + +Returns the catalog of available report types. Each entry advertises the report's key, supported output formats, whether it can be collated across tenants, accepted parameters, and tags. + +**Required API scope(s)**: `Reports.Read` + +**Cmdlet**: `Get-InforcerReportType` + +**Response**: Array of [ReportType](#reporttype) objects (under `data`). + +#### `POST /beta/reports/runs` + +Queues one or more report runs against a set of target tenants. Returns one run record per (report × tenant) combination unless `collate: true` is set on a `collatable: true` type, in which case a single cross-tenant output is produced. + +**Required API scope(s)**: `Reports.Run` + +**Cmdlet**: `Invoke-InforcerReport` + +**Request body**: + +```json +{ + "reports": [ + { + "type": "ActiveUserCount", + "outputFormat": "csv", + "collate": false, + "parameters": { "report-period": "30" } + } + ], + "tenants": { "includeTenants": [482, 483] } +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reports[].type` | string | Yes | Report type key from `GET /reports/types`. Case-insensitive. | +| `reports[].outputFormat` | string | Yes | One of the `outputFormats` advertised by the type. Case-insensitive. | +| `reports[].collate` | boolean | No | When `true` on a `collatable: true` type, produces a single cross-tenant output. | +| `reports[].parameters` | object | No | Type-specific parameters (string-keyed, string-valued). Unknown keys are silently accepted today; the module rejects them client-side. | +| `tenants.includeTenants` | int[] | Yes | Numeric Client Tenant IDs. Duplicates are deduped by the server. | + +**Response**: Array of [ReportRun](#reportrun) objects (one per created run). + +> **Known server-side bug**: Duplicate `(type, outputFormat)` entries currently render all duplicates in the last-listed format. The module deduplicates client-side before POST. + +#### `GET /beta/reports/runs` + +Lists report runs across all tenants the caller has visibility into. Server-side cap: `maxItems: 500`, last 7 days. Query parameters (`?status=`, `?since=`, `?limit=`, ...) are currently ignored — filter client-side. + +**Required API scope(s)**: `Reports.Read` + +**Cmdlet**: `Get-InforcerReportRun` + +> **Propagation lag**: a newly-queued run does not appear in this list for ~4 minutes. For freshly-queued runs, poll `GET /beta/reports/runs/{runId}/outputs` directly (404 until terminal, 200 once complete). + +**Response**: Array of [ReportRun](#reportrun) objects (under `data`). + +#### `GET /beta/reports/runs/{runId}/outputs` + +Returns the list of outputs for a single terminal run. Used as the polling probe for run completion (bypasses the list-endpoint propagation lag). + +**Required API scope(s)**: `Reports.Read` + +**Cmdlet**: `Test-InforcerReportRunTerminal` *(private helper, used by `Invoke-InforcerReport -Wait`, `Get-InforcerReportRun -Wait`, and `Get-InforcerReportRun -IncludeOutputs`)* + +| Parameter | In | Type | Required | Description | +|-----------|----|------|----------|-------------| +| `runId` | path | string (GUID) | Yes | The run identifier returned by `POST /reports/runs`. | + +**Responses:** + +| Code | Body | Meaning | +|------|------|---------| +| 200 | `{ data: [ReportOutput, ...] }` | Run is in a terminal state (`completed` or `completedWithErrors`) and outputs are available. | +| 200 | `{ data: [] }` | Run is visible to the API key, but **no outputs are within the key's tenant scope.** Collated outputs (covering multiple tenants) are only returned when the key covers every tenant the run targeted. | +| 404 | — | **Deliberately indistinguishable across four cases**: (1) run does not exist, (2) run belongs to a different client, (3) run is not in a terminal state (still `running`, or `failed`), (4) output ID does not belong to this run. Designed this way to avoid leaking run existence across clients. | + +> **Polling implication**: `Invoke-InforcerReport -Wait` and `Get-InforcerReportRun -Wait` cannot distinguish a still-running run from a `failed` run — both 404 indefinitely. The cmdlet times out cleanly after `-TimeoutSeconds`. To detect `failed` status, query `GET /beta/reports/runs` (subject to the 4-minute lag). + +#### `GET /beta/reports/runs/{runId}/outputs/{outputId}` + +Downloads the raw bytes of a single output. Returns proper MIME type and a `Content-Disposition: attachment; filename=...; filename*=UTF-8''...` header. No `Range` / `ETag` support — every fetch is a full GET. + +**Required API scope(s)**: `Reports.Read` + +**Cmdlet**: `Save-InforcerReportOutput` + +| Parameter | In | Type | Required | Description | +|-----------|----|------|----------|-------------| +| `runId` | path | string (GUID) | Yes | Run identifier. | +| `outputId` | path | string | Yes | Output identifier from the outputs list. | + +**404 is deliberately indistinguishable** across: run doesn't exist, run belongs to a different client, run isn't in a terminal state, output ID doesn't belong to the run, or the output's tenant is outside the key's tenant scope. Designed to avoid leaking run/output existence. + +**Response**: Raw bytes (CSV, JSON, HTML, PDF, etc. depending on `outputFormat`). + --- ## Schemas @@ -651,6 +762,62 @@ An Entra ID directory role definition. **PSTypeName:** `InforcerCommunity.Role` +### ReportType + +A catalog entry from `GET /beta/reports/types`. Field names verified against `api-uk.inforcer.com`. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| key | string | Yes | Stable identifier (e.g. `ActiveUserCount`, `CopilotAdoption`). Case-insensitive in requests. | +| name | string | No | Display name. | +| description | string | No | One-line description of what the report contains. | +| collatable | boolean | No | Whether the type can produce a single cross-tenant output when `collate: true` is passed. | +| supportedOutputFormats | string[] | No | Output formats accepted for this type at `POST /reports/runs`. Observed values: `csv`, `json`, `pdf`, `html`. | +| tags | string[] | No | Categorization tags (e.g. `Identity`, `Adoption`, `Security`). | +| requiredParameters | array\ | No | Per-type parameters (e.g. `report-period`, `assessment-id`). Empty array `[]` for types that take no parameters. Each entry typically exposes `key`, `name`, `type`, and value/range constraints. | + +**PSTypeName:** `InforcerCommunity.ReportType`. The module also exposes `OutputFormats` and `Parameters` as PascalCase back-compat aliases over `supportedOutputFormats` / `requiredParameters`. + +### ReportRun + +A run record from `GET /beta/reports/runs` (list) or `POST /beta/reports/runs` (create). **One run batches the full reports[] array submitted in the POST**, so the run carries plural `reportTypes` and `outputFormats` arrays — not singular fields. Field names verified against `api-uk.inforcer.com`. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| runId | string (GUID) | Yes | Unique run identifier. **POST response shape is `{ data: { runId: "" } }` — only the runId is returned at create time.** | +| status | string | Yes | One of `running`, `completed`, `completedWithErrors`, `failed`. | +| reportTypes | string[] | No | The report type keys batched into this run (case may differ from catalog — e.g. `activeusercount` instead of `ActiveUserCount`). | +| outputFormats | string[] | No | The output formats requested across the run's reports. | +| triggeredByType | string | No | `user` or `scheduled`. | +| triggeredBy | string | No | Identity that triggered the run. | +| createdAt | string (ISO 8601) | No | When the run was created. | +| startedAt | string (ISO 8601) | No | When processing began (typically the same instant as `createdAt`). | +| completedAt | string (ISO 8601) | No | When the run reached a terminal status. | +| outputCount | int | No | Number of outputs produced (0 is valid — e.g. no Copilot data, or no tenant-visible outputs). | +| outputs | array\<[ReportOutput](#reportoutput)\> | No | Embedded when `-IncludeOutputs` / `-Wait` is used; absent from the raw list endpoint. | + +**PSTypeName:** `InforcerCommunity.ReportRun`. `Id` is exposed as a PascalCase alias over `runId` for `-Id`-style pipeline binding. + +### ReportOutput + +An output record from `GET /beta/reports/runs/{runId}/outputs`. The actual bytes are fetched via the per-output download endpoint (see `Save-InforcerReportOutput`). Field names verified against `api-uk.inforcer.com`. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| id | string (GUID) | Yes | Output identifier (unique within the run). Aliased as `OutputId`. | +| reportType | string | Yes | Report type key for this output (e.g. `ActiveUserCount`). | +| tenantId | int | Yes | Numeric Client Tenant ID. Absent on collated cross-tenant outputs. | +| format | string | Yes | Output format (`csv`, `json`, `pdf`, `html`). Aliased as `OutputFormat`. | +| sizeBytes | int | Yes | Size in bytes. Aliased as `FileSize`. | + +**Server does not return**: a `fileName`, `contentType`, or `createdAt` on the output record — the filename and MIME type come from the download endpoint's `Content-Disposition` and `Content-Type` headers respectively. + +**Module-attached property**: `RunId` is attached client-side when needed for pipeline binding to `Save-InforcerReportOutput`. + +**PSTypeName:** `InforcerCommunity.ReportOutput` + +> **Empty-result caveat**: Some report types produce a 4-byte UTF-8 BOM CSV (`EF BB BF 0A`) when there's no data, while others omit the output entirely. The cmdlet does not try to disambiguate "report ran with empty result" from "report didn't run for this tenant"; callers should inspect `fileSize` and/or content. + --- ## Response Wrapper diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index 2d439aa..bf53dab 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -799,6 +799,225 @@ FindingsMessage : All users have MFA enabled. --- +## Get-InforcerReportType + +Lists available report types from the Inforcer Reports API. The catalog is cached in the module session; pass `-Force` to refetch. + +**Endpoints called**: `GET /beta/reports/types` +**Required API scope(s)**: `Reports.Read` + +**Output schema**: [ReportType](./API-REFERENCE.md#reporttype) + +| Parameter | Type | Mandatory | Description | +|-----------|------|-----------|-------------| +| **Key** | String | No | Filter to a single report type by key (case-insensitive). Aliases: `-Name`, `-ReportType`. | +| **Tag** | String | No | Filter to types containing the specified tag. | +| **OutputFormat** | String | No | Filter to types that support the specified output format. | +| **Force** | Switch | No | Bypass the in-memory cache and refetch the catalog. | +| **Format** | String | No | `Raw` (default). | +| **OutputType** | String | No | `PowerShellObject` (default) or `JsonObject`. JSON uses Depth 100. | + +### Examples + +```powershell +Get-InforcerReportType +Get-InforcerReportType -Key CopilotAdoption +Get-InforcerReportType -OutputFormat pdf +Get-InforcerReportType -Tag security +Get-InforcerReportType -OutputType JsonObject +``` + +### Example output + +``` +Key : CopilotAdoption +Name : Copilot Adoption +Description : Usage trends and adoption rates for Microsoft 365 Copilot. +Collatable : True +OutputFormats : csv, json +Tags : Copilot, Productivity +Parameters : report-period +``` + +--- + +## Invoke-InforcerReport + +Queues one or more Inforcer reports, polls the outputs endpoint until each run is terminal, and saves the outputs to disk. Use `-NoWait` to return immediately, or `-NoSave` to poll without downloading. + +**Endpoints called**: `POST /beta/reports/runs`, `GET /beta/reports/runs/{runId}/outputs`, `GET /beta/reports/runs/{runId}/outputs/{outputId}`, `GET /beta/tenants` *(GUID/name lookup)* +**Required API scope(s)**: `Reports.Read` + `Reports.Run` + `Tenants.Read`† + +| Parameter | Type | Mandatory | Description | +|-----------|------|-----------|-------------| +| **ReportType** | String[] | Yes | One or more report type keys. Aliases: `-Key`. | +| **OutputFormat** | String[] | Yes | One or more output formats. Zip rules: 1 format broadcasts to all reports; N formats pair by index; any other count errors. | +| **TenantId** | Object[] | Yes | One or more tenants (numeric ID, GUID, or name). Alias: `-ClientTenantId`. | +| **ReportPeriod** | Int | No | Days for the `report-period` parameter. CopilotAdoption / ShadowAiDetection auto-default to 30. | +| **AssessmentId** | String | No | Required when `-ReportType Assessment`. The assessment identifier (alphanumeric string, not a GUID — Inforcer assessment IDs look like `l1f8wd29pl44pp1j66r9`). Tab-completes against `Get-InforcerAssessment`. | +| **Parameter** | Hashtable | No | Escape hatch for future API parameters. Keys validated against the type's catalog entry. | +| **Collate** | Switch | No | Request a single cross-tenant output. Rejected when the type's catalog says `collatable: false`. | +| **NoWait** | Switch | No | Return immediately after `POST` with run identifiers. Skip polling and download. | +| **NoSave** | Switch | No | Poll until terminal but do not write files. Returns outputs metadata. | +| **Open** | Switch | No | After each output is saved, launch it with the OS default handler (`Invoke-Item`). Ignored when `-NoWait` or `-NoSave` is also set (emits a warning). Aliases: `-Show`, `-ShowResult`. | +| **OutputPath** | String | No | Directory for downloaded outputs. Default: current working directory. | +| **TimeoutSeconds** | Int | No | Maximum wait per run when polling. Default: 600. | +| **PollIntervalSeconds** | Int | No | Initial poll interval (doubles up to 15s cap). Default: 2. | +| **Format** | String | No | `Raw` (default). | +| **OutputType** | String | No | `PowerShellObject` (default) or `JsonObject`. | + +> † `Tenants.Read` is only consumed when `-TenantId` is a GUID or tenant name (which triggers `Resolve-InforcerTenantId`). Numeric `-TenantId` calls skip the tenant-list lookup. + +### Examples + +```powershell +# Single report, single tenant — saves ActiveUserCount-.csv to the current directory +Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 + +# Two reports, paired formats — TenantAuditReport→pdf, ActiveUserCount→csv +Invoke-InforcerReport -ReportType TenantAuditReport, ActiveUserCount -OutputFormat pdf, csv -TenantId 482 + +# Cross-tenant, broadcast format — same csv format for both +Invoke-InforcerReport -ReportType CopilotAdoption -OutputFormat csv -TenantId 482, 483 + +# Async — return RunIds immediately +Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 -NoWait + +# Assessment report +Invoke-InforcerReport -ReportType Assessment -AssessmentId l1f8wd29pl44pp1j66r9 -OutputFormat pdf -TenantId 482 + +# Save and immediately open the file with the OS default handler +Invoke-InforcerReport -ReportType TenantAuditReport -OutputFormat html -TenantId 482 -Open + +# Discover-then-run pipeline — every Security-tagged type queued in one batch (Key alias on +# Get-InforcerReportType binds to -ReportType via ValueFromPipelineByPropertyName). +Get-InforcerReportType -Tag Security | Invoke-InforcerReport -OutputFormat csv -TenantId 482 + +# JSON output +Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 -OutputType JsonObject +``` + +### Example output (default sync + save) + +``` +RunId : 094a49ed-b9b8-492b-870f-0f76fd3b2954 +ReportType : ActiveUserCount +OutputFormat : csv +TenantId : 482 +FilePath : /path/to/output/ActiveUserCount_2026-06-26.csv +FileName : ActiveUserCount_2026-06-26.csv +FileSize : 12480 +ContentType : text/csv +``` + +### Example output (-NoWait) + +The POST response is intentionally minimal — `RunId` is the only property the API returns. Poll status with `Get-InforcerReportRun -RunId ` or `Get-InforcerReportRun -RunId -Wait -IncludeOutputs`. + +``` +RunId : 094a49ed-b9b8-492b-870f-0f76fd3b2954 +``` + +--- + +## Get-InforcerReportRun + +Lists report runs from the Inforcer Reports API. The list endpoint has a ~4-minute propagation lag for fresh runs and a server-side 500-item / 7-day cap; use `-Wait` with `-RunId` to bypass the lag by polling the outputs endpoint directly. + +**Endpoints called**: `GET /beta/reports/runs`, `GET /beta/reports/runs/{runId}/outputs` *(with `-Wait` or `-IncludeOutputs`)* +**Required API scope(s)**: `Reports.Read` + +**Output schema**: [ReportRun](./API-REFERENCE.md#reportrun) + +| Parameter | Type | Mandatory | Description | +|-----------|------|-----------|-------------| +| **RunId** | Guid | No | Filter to a single run. Required with `-Wait`. | +| **Wait** | Switch | No | Poll the outputs endpoint until the run is terminal (or `-TimeoutSeconds` elapses). | +| **IncludeOutputs** | Switch | No | Fetch outputs for every returned run. Adds one API call per run. | +| **TimeoutSeconds** | Int | No | Maximum wait when polling. Default: 600. | +| **PollIntervalSeconds** | Int | No | Initial poll interval. Default: 2. | +| **Format** | String | No | `Raw` (default). | +| **OutputType** | String | No | `PowerShellObject` (default) or `JsonObject`. | + +### Examples + +```powershell +Get-InforcerReportRun +Get-InforcerReportRun -RunId 094a49ed-b9b8-492b-870f-0f76fd3b2954 +Get-InforcerReportRun -RunId 094a49ed-b9b8-492b-870f-0f76fd3b2954 -Wait +Get-InforcerReportRun -IncludeOutputs +Get-InforcerReportRun -OutputType JsonObject +``` + +### Example output + +A run can batch multiple report types and output formats under a single `RunId`, so `ReportTypes` and `OutputFormats` are plural arrays. + +``` +RunId : 094a49ed-b9b8-492b-870f-0f76fd3b2954 +Status : completed +ReportTypes : {activeusercount, globaladmins} +OutputFormats : {csv} +TriggeredByType : user +CreatedAt : 2026-06-26T14:01:23Z +CompletedAt : 2026-06-26T14:01:29Z +OutputCount : 2 +``` + +--- + +## Save-InforcerReportOutput + +Downloads a report output to disk. Pipeline-friendly: pipe output records from `Invoke-InforcerReport -NoSave` or `Get-InforcerReportRun -IncludeOutputs` to download every output in bulk. + +**Endpoints called**: `GET /beta/reports/runs/{runId}/outputs/{outputId}` +**Required API scope(s)**: `Reports.Read` + +| Parameter | Type | Mandatory | Description | +|-----------|------|-----------|-------------| +| **RunId** | Guid | Yes | The run identifier. Pipeline-bindable. | +| **OutputId** | String | Yes | The output identifier. Pipeline-bindable. Alias: `-Id`. | +| **ReportType** | String | No | Pipeline-bindable pass-through. Auto-populated when piped from `Invoke-InforcerReport -NoSave` or `Get-InforcerReportRun -IncludeOutputs`. Surfaced on the result object for parity with `Invoke-InforcerReport`. `$null` when called standalone. | +| **OutputFormat** | String | No | Pipeline-bindable pass-through. Same behavior as `-ReportType`. | +| **TenantId** | Object | No | Pipeline-bindable pass-through. Alias: `-ClientTenantId`. Same behavior as `-ReportType`. | +| **OutputPath** | String | No | Target directory. Default: current working directory. Created if missing. | +| **FileName** | String | No | Override the server-suggested filename (sanitized for filesystem safety). | +| **OutputType** | String | No | `PowerShellObject` (default) or `JsonObject`. | + +### Examples + +```powershell +# Single download +Save-InforcerReportOutput -RunId 094a49ed-b9b8-492b-870f-0f76fd3b2954 -OutputId out-1 + +# Bulk download via pipeline +Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 -NoSave | + Save-InforcerReportOutput -OutputPath ./reports + +# Bulk download from every visible run +Get-InforcerReportRun -IncludeOutputs | + ForEach-Object { $_.outputs } | + Save-InforcerReportOutput -OutputPath ./bulk +``` + +### Example output + +``` +RunId : 094a49ed-b9b8-492b-870f-0f76fd3b2954 +OutputId : 1f2e3d4c-... +TenantId : 482 +ReportType : ActiveUserCount +OutputFormat : csv +FilePath : /path/to/output/ActiveUserCount_2026-06-26.csv +FileName : ActiveUserCount_2026-06-26.csv +FileSize : 12480 +ContentType : text/csv +``` + +`TenantId`, `ReportType`, and `OutputFormat` are populated automatically when piped from `Invoke-InforcerReport -NoSave` or `Get-InforcerReportRun -IncludeOutputs`; they are `$null` when the cmdlet is called standalone with bare `-RunId` / `-OutputId`. + +--- + ## See also - **[API-REFERENCE.md](./API-REFERENCE.md)** — Detailed API schemas and response structures. diff --git a/docs/api-schema-snapshot.json b/docs/api-schema-snapshot.json index ecfcc23..f70a447 100644 --- a/docs/api-schema-snapshot.json +++ b/docs/api-schema-snapshot.json @@ -1,5 +1,13 @@ { - "generatedAt": "2026-04-18T06:36:43Z", + "generatedAt": "2026-06-29T08:01:48Z", + "_meta": { + "notes": [ + "Types use suffixes: '?' = nullable, '*' = pattern key. Bare types are observed-required-non-null in the sample.", + "Arrays use `_itemProperties` when the documented item shape is known; bare `\"array\"` only when items are truly schemaless (e.g. variant strings).", + "Nullable handling: API contract types are documented at docs/API-REFERENCE.md and considered the source of truth; sampled responses that happen to contain nulls do not collapse the documented type.", + "Reports / Assessment endpoints are part of the public surface in v0.5.0 and are included here." + ] + }, "endpoints": { "GET /beta/tenants": { "properties": { @@ -7,11 +15,11 @@ "tenantFriendlyName": "string", "tenantDnsName": "string", "msTenantId": "string", - "secureScore": "integer", + "secureScore": "number", "isBaseline": "boolean", "lastBackupTimestamp": "datetime", "recentChanges": "integer", - "policyDiff": "string", + "policyDiff": "string?", "tags": "array", "alignmentSummaries": { "_type": "array", @@ -19,9 +27,9 @@ "alignedBaselineTenantId": "integer", "alignedBaselineId": "string", "alignedBaselineName": "string", - "alignmentScore": "integer", - "alignedThreshold": "integer", - "semiAlignedThreshold": "integer", + "alignmentScore": "number", + "alignedThreshold": "number", + "semiAlignedThreshold": "number", "lastAlignmentDateTime": "datetime" } }, @@ -43,7 +51,15 @@ "baselineMsTenantId": "string", "alignedThreshold": "integer", "semiAlignedThreshold": "integer", - "members": "array", + "members": { + "_type": "array", + "_itemProperties": { + "clientTenantId": "integer", + "tenantFriendlyName": "string", + "tenantDnsName": "string", + "msTenantId": "string" + } + }, "mode": "string", "autoAddNewPolicies": "boolean", "isComplete": "boolean", @@ -52,10 +68,10 @@ "_type": "array", "_itemProperties": { "policySnapshotId": "string", - "childCustomBaselineId": "null", - "policyCategoryProduct": "null", - "policyCategoryPrimaryGroup": "null", - "policyCategorySecondaryGroup": "null", + "childCustomBaselineId": "string?", + "policyCategoryProduct": "string?", + "policyCategoryPrimaryGroup": "string?", + "policyCategorySecondaryGroup": "string?", "alignAssignments": "boolean" } } @@ -65,7 +81,7 @@ "properties": { "tenantId": "integer", "tenantFriendlyName": "string", - "score": "integer", + "score": "number", "baselineGroupId": "string", "baselineGroupName": "string", "lastComparisonDateTime": "datetime" @@ -75,15 +91,15 @@ "properties": { "id": "string", "policyTypeId": "integer", - "name": "null", + "name": "string?", "displayName": "string", "friendlyName": "string", - "description": "null", + "description": "string?", "readOnly": "boolean", "product": "string", "primaryGroup": "string", "secondaryGroup": "string", - "platform": "null", + "platform": "string?", "policyCategoryId": "integer", "tags": "array", "policyData": "object" @@ -122,7 +138,7 @@ "_itemProperties": { "subjectPolicySnapshotId": "string", "subjectPolicySnapshotVersionId": "string", - "subjectPolicyGuid": "null", + "subjectPolicyGuid": "string?", "policyDataDiff": "array", "policyAssignmentsDiff": "array", "policyTags": "array", @@ -137,9 +153,9 @@ "_itemProperties": { "variableId": "string", "variableName": "string", - "baselineValue": "string", - "subjectValue": "string", - "description": "string", + "baselineValue": "string?", + "subjectValue": "string?", + "description": "string?", "propertyPath": "string", "isHidden": "boolean", "isBaselineOnly": "boolean" @@ -147,18 +163,18 @@ }, "similarPolicies": "array", "acceptedDeviations": "array", - "policyGuid": "null", + "policyGuid": "string?", "policyName": "string", - "policySnapshotId": "null", - "policySnapshotVersionId": "null", + "policySnapshotId": "string?", + "policySnapshotVersionId": "string?", "policyTypeId": "integer", "inforcerPolicyTypeName": "string", "policyCategoryId": "integer", "product": "string", "primaryGroup": "string", "secondaryGroup": "string", - "platform": "null", - "description": "null", + "platform": "string?", + "description": "string?", "isPolicyDeleteDisabled": "boolean", "isPolicyDeployable": "boolean", "policyTags": { @@ -166,7 +182,7 @@ "_itemProperties": { "id": "string", "name": "string", - "description": "null" + "description": "string?" } }, "readOnly": "boolean", @@ -202,12 +218,12 @@ "_properties": { "clientIp": "string", "clientIpv4": "string", - "clientIpv6": "null", + "clientIpv6": "string?", "nameLookup": { "_type": "object", - "_properties": { - "user:username:237": "string", - "user:displayName:237": "string" + "_patternProperties": { + "user:username:*": "string", + "user:displayName:*": "string" } } } @@ -218,9 +234,9 @@ "properties": { "id": "string", "displayName": "string", - "description": "string", - "mail": "string", - "visibility": "string", + "description": "string?", + "mail": "string?", + "visibility": "string?", "groupTypes": "array" } }, @@ -228,15 +244,15 @@ "properties": { "id": "string", "displayName": "string", - "description": "string", - "mail": "string", - "mailNickname": "string", - "visibility": "string", - "membershipRule": "null", + "description": "string?", + "mail": "string?", + "mailNickname": "string?", + "visibility": "string?", + "membershipRule": "string?", "groupTypes": "array", "createdDateTime": "datetime", "mailEnabled": "boolean", - "onPremisesSyncEnabled": "null", + "onPremisesSyncEnabled": "boolean?", "members": { "_type": "array", "_itemProperties": { @@ -252,11 +268,152 @@ "id": "string", "templateId": "string", "displayName": "string", - "description": "string", + "description": "string?", "isBuiltIn": "boolean", "isEnabled": "boolean", "isPrivileged": "boolean" } + }, + "GET /beta/assessments": { + "properties": { + "id": "string", + "name": "string", + "description": "string?", + "tags": "array", + "assessmentType": "string", + "lastUpdated": "datetime", + "created": "datetime" + } + }, + "POST /beta/tenants/{tenantId}/assessments/{assessmentId}/runs": { + "properties": { + "assessment": "string", + "name": "string", + "results": { + "_type": "array", + "_itemProperties": { + "id": "string", + "key": "string", + "name": "string", + "category": "string", + "subCategory": "string?", + "description": "string?", + "importance": "string", + "impact": "string?", + "rationale": "string?", + "remediation": "string?", + "findings": { + "_type": "object", + "_properties": { + "compliant": "boolean", + "message": "string?", + "scores": { + "_type": "array", + "_itemProperties": { + "objectId": "string", + "score": "number", + "objectName": "string?", + "passes": "array", + "violations": "array", + "warnings": "array" + } + } + } + }, + "frameworks": "array", + "tags": "array", + "version": "string?", + "created": "datetime", + "lastUpdated": "datetime" + } + } + } + }, + "GET /beta/reports/types": { + "properties": { + "key": "string", + "name": "string", + "description": "string?", + "collatable": "boolean", + "supportedOutputFormats": "array", + "requiredParameters": "array", + "tags": "array" + } + }, + "POST /beta/reports/runs": { + "properties": { + "runId": "string", + "status": "string", + "reportTypes": "array", + "outputFormats": "array", + "triggeredByType": "string", + "createdAt": "datetime", + "startedAt": "datetime?", + "completedAt": "datetime?", + "outputCount": "integer" + } + }, + "GET /beta/reports/runs": { + "properties": { + "runId": "string", + "status": "string", + "reportTypes": "array", + "outputFormats": "array", + "triggeredByType": "string", + "createdAt": "datetime", + "startedAt": "datetime?", + "completedAt": "datetime?", + "outputCount": "integer" + } + }, + "GET /beta/reports/runs/{runId}/outputs": { + "properties": { + "outputs": { + "_type": "array", + "_itemProperties": { + "id": "string", + "reportType": "string", + "tenantId": "integer", + "format": "string", + "sizeBytes": "integer" + } + } + } + }, + "GET /beta/reports/runs/{runId}/outputs/{outputId}": { + "_responseType": "binary", + "_headers": { + "Content-Disposition": "string (RFC 5987 filename*= or filename=)", + "Content-Type": "string", + "x-correlation-id": "string" + } + }, + "GET /beta/tenants/{tenantId}/users": { + "properties": { + "id": "string", + "displayName": "string", + "userPrincipalName": "string", + "userType": "string?", + "department": "string?", + "assignedLicenses": "array", + "isGlobalAdmin": "boolean", + "isMfaCapable": "boolean" + } + }, + "GET /beta/tenants/{tenantId}/users/{userId}": { + "properties": { + "id": "string", + "displayName": "string", + "userPrincipalName": "string", + "userType": "string?", + "department": "string?", + "mail": "string?", + "accountEnabled": "boolean", + "isGlobalAdmin": "boolean", + "isCloudOnly": "boolean", + "isMfaRegistered": "boolean", + "riskLevel": "string?" + } } } } diff --git a/module/InforcerCommunity.Format.ps1xml b/module/InforcerCommunity.Format.ps1xml index 66dd407..1fbfbda 100644 --- a/module/InforcerCommunity.Format.ps1xml +++ b/module/InforcerCommunity.Format.ps1xml @@ -452,5 +452,135 @@ + + + InforcerCommunity.ReportType.Default + + InforcerCommunity.ReportType + + + + + + Key + Name + Description + Collatable + + $formats = $null + foreach ($p in 'SupportedOutputFormats','OutputFormats','supportedOutputFormats','outputFormats','supportedFormats','formats') { + if ($_.PSObject.Properties[$p] -and $null -ne $_.PSObject.Properties[$p].Value) { + $formats = $_.PSObject.Properties[$p].Value + break + } + } + if ($formats) { ($formats -join ', ') } else { '' } + + Tags + + $p = $_.PSObject.Properties['RequiredParameters'] + if (-not $p) { $p = $_.PSObject.Properties['Parameters'] } + if (-not $p) { $p = $_.PSObject.Properties['requiredParameters'] } + if (-not $p) { $p = $_.PSObject.Properties['parameters'] } + $rendered = '(none)' + if ($p -and $null -ne $p.Value) { + $keys = foreach ($entry in @($p.Value)) { + if ($entry -is [string]) { $entry } + elseif ($entry.PSObject.Properties['key']) { $entry.PSObject.Properties['key'].Value } + elseif ($entry.PSObject.Properties['name']) { $entry.PSObject.Properties['name'].Value } + } + $filtered = $keys | Where-Object { $_ } + if ($filtered) { $rendered = ($filtered -join ', ') } + } + $rendered + + + + + + + + + + InforcerCommunity.ReportRun.Default + + InforcerCommunity.ReportRun + + + + + + RunId + Status + + $p = $_.PSObject.Properties['ReportTypes'] + if (-not $p) { $p = $_.PSObject.Properties['reportTypes'] } + if ($p -and $null -ne $p.Value) { @($p.Value) -join ', ' } else { '' } + + + $p = $_.PSObject.Properties['OutputFormats'] + if (-not $p) { $p = $_.PSObject.Properties['outputFormats'] } + if ($p -and $null -ne $p.Value) { @($p.Value) -join ', ' } else { '' } + + TriggeredByType + CreatedAt + CompletedAt + OutputCount + + + + + + + + + InforcerCommunity.ReportRunResult.Default + + InforcerCommunity.ReportRunResult + + + + + + RunId + ReportType + OutputFormat + TenantId + FilePath + FileName + FileSize + ContentType + + + + + + + + + InforcerCommunity.ReportOutput.Default + + InforcerCommunity.ReportOutput + + + + + + OutputId + RunId + ReportType + OutputFormat + TenantId + FileSize + + + + + + diff --git a/module/InforcerCommunity.psd1 b/module/InforcerCommunity.psd1 index 9e1c5b7..9f578f8 100644 --- a/module/InforcerCommunity.psd1 +++ b/module/InforcerCommunity.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'InforcerCommunity.psm1' - ModuleVersion = '0.4.0' + ModuleVersion = '0.5.0' GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' Author = 'Roy Klooster' Description = 'Community PowerShell module for the Inforcer API. Created by Roy Klooster. Not owned or officially maintained by Inforcer.' @@ -25,6 +25,10 @@ 'Compare-InforcerEnvironments' 'Get-InforcerAssessment' 'Invoke-InforcerAssessment' + 'Get-InforcerReportType' + 'Invoke-InforcerReport' + 'Get-InforcerReportRun' + 'Save-InforcerReportOutput' ) CmdletsToExport = @() VariablesToExport = @() @@ -33,7 +37,7 @@ PSData = @{ ProjectUri = 'https://github.com/royklo/InforcerCommunity' LicenseUri = 'https://github.com/royklo/InforcerCommunity/blob/main/LICENSE' - ReleaseNotes = 'v0.4.0: New assessment cmdlets (Get-InforcerAssessment, Invoke-InforcerAssessment) with single/multi-tenant support, HTML/CSV/JSON export, interactive matrix reports, and async progress.' + ReleaseNotes = 'v0.5.0: New Reports API cmdlets (Get-InforcerReportType, Invoke-InforcerReport, Get-InforcerReportRun, Save-InforcerReportOutput) wrapping /beta/reports/*. Sync+save default, -NoWait/-NoSave modes, -Open switch to launch saved files via OS default handler with extension allowlist, pipeline-bindable catalog objects, three-phase Write-Progress, and proper Content-Disposition parsing. Connect-Inforcer accepts any valid API key regardless of scope (envelope-shape detection) and best-effort primes the Reports catalog cache for instant TAB completion. Invoke-InforcerApiRequest now handles all three error envelope shapes (app-layer, APIM gateway, RFC 9110 ProblemDetails), captures x-correlation-id, and surfaces structured errors[] field details so validation failures tell you which field actually broke. Dynamic -AssessmentId completer with three-state UX (not connected / scope denied / connected) caching denial on 401 and 403. Breaking: Invoke-InforcerReport ConfirmImpact bumped from Low to Medium. See CHANGELOG.md for full details.' Tags = @('Inforcer', 'API', 'Community') } } diff --git a/module/InforcerCommunity.psm1 b/module/InforcerCommunity.psm1 index 7afdcea..d9ff1b6 100644 --- a/module/InforcerCommunity.psm1 +++ b/module/InforcerCommunity.psm1 @@ -5,3 +5,15 @@ Get-ChildItem -Path $privatePath -Filter '*.ps1' -Recurse | ForEach-Object { . $ # Public cmdlets (exported via manifest) $publicPath = Join-Path $PSScriptRoot 'Public' Get-ChildItem -Path $publicPath -Filter '*.ps1' -Recurse | ForEach-Object { . $_.FullName } + +# Eager-initialize the monotonically-increasing progress ID seed used by Invoke-InforcerReport. +# Done here at module load (single-threaded by definition) to eliminate the race where two +# parallel runspaces both observe $null and lazy-init to the same starting value. +$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) { + Write-Warning 'InforcerCommunity module unloaded — active session and caches are cleared. Re-run Connect-Inforcer to continue.' + } +} diff --git a/module/Private/Add-InforcerPropertyAliases.ps1 b/module/Private/Add-InforcerPropertyAliases.ps1 index 088ed89..c02e599 100644 --- a/module/Private/Add-InforcerPropertyAliases.ps1 +++ b/module/Private/Add-InforcerPropertyAliases.ps1 @@ -17,7 +17,7 @@ function Add-InforcerPropertyAliases { [object]$InputObject, [Parameter(Mandatory = $true)] - [ValidateSet('Tenant', 'Baseline', 'Policy', 'AlignmentScore', 'AlignmentDetail', 'AuditEvent', 'UserSummary', 'User', 'GroupSummary', 'Group', 'Role', 'Assessment')] + [ValidateSet('Tenant', 'Baseline', 'Policy', 'AlignmentScore', 'AlignmentDetail', 'AuditEvent', 'UserSummary', 'User', 'GroupSummary', 'Group', 'Role', 'Assessment', 'ReportType', 'ReportRun', 'ReportOutput')] [string]$ObjectType ) @@ -342,13 +342,50 @@ function Add-InforcerPropertyAliases { AddAliasIfExists $obj 'AssessmentType' 'assessmentType' AddAliasIfExists $obj 'LastUpdated' 'lastUpdated' AddAliasIfExists $obj 'Created' 'created' - # Convert tags array to comma-separated string for display - $tagsProp = $obj.PSObject.Properties['tags'] - if ($tagsProp -and $tagsProp.Value -is [array]) { - $tagsProp.Value = ($tagsProp.Value | Where-Object { $_ }) -join ', ' - } + # Keep raw 'tags' shape intact (array OR comma-separated string from the API); + # downstream filters (e.g. Get-InforcerReportType -Tag) rely on the raw shape, + # and the Format.ps1xml view joins for display. + AddAliasIfExists $obj 'Tags' 'tags' + } + 'ReportType' { + AddAliasIfExists $obj 'Key' 'key' + AddAliasIfExists $obj 'Name' 'name' + AddAliasIfExists $obj 'Description' 'description' + AddAliasIfExists $obj 'Collatable' 'collatable' + # API uses 'supportedOutputFormats' (confirmed against api-uk.inforcer.com beta). + AddAliasIfExists $obj 'SupportedOutputFormats' 'supportedOutputFormats' + AddAliasIfExists $obj 'OutputFormats' 'supportedOutputFormats' + AddAliasIfExists $obj 'RequiredParameters' 'requiredParameters' + AddAliasIfExists $obj 'Parameters' 'requiredParameters' + # Keep raw 'tags' shape intact (array OR comma-separated string from the API); + # downstream filters (e.g. Get-InforcerReportType -Tag) rely on the raw shape, + # and the Format.ps1xml view joins for display. AddAliasIfExists $obj 'Tags' 'tags' } + 'ReportRun' { + # API uses 'runId' (confirmed). Each run can batch multiple report types + # and output formats — hence the plurals. Field set verified against api-uk.inforcer.com. + AddAliasIfExists $obj 'RunId' 'runId' + AddAliasIfExists $obj 'Id' 'runId' + AddAliasIfExists $obj 'Status' 'status' + AddAliasIfExists $obj 'ReportTypes' 'reportTypes' + AddAliasIfExists $obj 'OutputFormats' 'outputFormats' + AddAliasIfExists $obj 'TriggeredByType' 'triggeredByType' + AddAliasIfExists $obj 'CreatedAt' 'createdAt' + AddAliasIfExists $obj 'StartedAt' 'startedAt' + AddAliasIfExists $obj 'CompletedAt' 'completedAt' + AddAliasIfExists $obj 'OutputCount' 'outputCount' + } + 'ReportOutput' { + # Output record uses: id (output id), reportType, tenantId, format, sizeBytes + AddAliasIfExists $obj 'OutputId' 'id' + AddAliasIfExists $obj 'Id' 'id' + AddAliasIfExists $obj 'RunId' 'runId' + AddAliasIfExists $obj 'TenantId' 'tenantId' + AddAliasIfExists $obj 'ReportType' 'reportType' + AddAliasIfExists $obj 'OutputFormat' 'format' + AddAliasIfExists $obj 'FileSize' 'sizeBytes' + } } $obj diff --git a/module/Private/Compare-InforcerDocModels.ps1 b/module/Private/Compare-InforcerDocModels.ps1 index de7d46b..df0839b 100644 --- a/module/Private/Compare-InforcerDocModels.ps1 +++ b/module/Private/Compare-InforcerDocModels.ps1 @@ -490,7 +490,7 @@ function Compare-InforcerDocModels { foreach ($ss in $linkedScript.Settings) { $scriptJson[$ss.Name] = $ss.Value } - $scriptJsonStr = $scriptJson | ConvertTo-Json -Depth 5 -Compress + $scriptJsonStr = $scriptJson | ConvertTo-Json -Depth 100 -Compress [void]$settingsSummary.Add(@{ Name = 'Linked Compliance Script'; Value = $scriptJsonStr }) } } diff --git a/module/Private/Format-InforcerErrorDetail.ps1 b/module/Private/Format-InforcerErrorDetail.ps1 new file mode 100644 index 0000000..c75b865 --- /dev/null +++ b/module/Private/Format-InforcerErrorDetail.ps1 @@ -0,0 +1,70 @@ +function Format-InforcerErrorDetail { + <# + .SYNOPSIS + Renders the structured `errors[]` array from an Inforcer API envelope into a single + human-readable string suitable for appending to a top-level error message. + .DESCRIPTION + The Inforcer app-layer envelope shape is `{ success, message, errors[], errorCode }`. + On validation failures the top-level `message` is often a generic placeholder + ("Validation failed, see errors for details") and the actionable information lives + in the `errors[]` array. Each entry can be: + + * a plain string → kept verbatim + * an object with field/property/name + message/detail + code/errorCode + → rendered as "field (code) message" + + Returns $null when the array is missing, empty, or contains no usable detail. + .PARAMETER Errors + The parsed `errors` array (whatever ConvertFrom-Json produced for that property). + .OUTPUTS + String (semicolon-joined) or $null. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $false, Position = 0)] + $Errors + ) + + if ($null -eq $Errors) { return $null } + $entries = @($Errors) + if ($entries.Count -eq 0) { return $null } + + $rendered = [System.Collections.Generic.List[string]]::new() + foreach ($e in $entries) { + if ($null -eq $e) { continue } + if ($e -is [string]) { + $rendered.Add($e.Trim()) + continue + } + if (-not ($e.PSObject -and $e.PSObject.Properties)) { continue } + + $field = ($e.PSObject.Properties['field'].Value -as [string]) + if (-not $field) { $field = ($e.PSObject.Properties['property'].Value -as [string]) } + if (-not $field) { $field = ($e.PSObject.Properties['name'].Value -as [string]) } + $message = ($e.PSObject.Properties['message'].Value -as [string]) + if (-not $message) { $message = ($e.PSObject.Properties['detail'].Value -as [string]) } + $code = ($e.PSObject.Properties['code'].Value -as [string]) + if (-not $code) { $code = ($e.PSObject.Properties['errorCode'].Value -as [string]) } + + $parts = [System.Collections.Generic.List[string]]::new() + if ($field) { $parts.Add($field) } + if ($code) { $parts.Add("($code)") } + if ($message) { $parts.Add($message) } + if ($parts.Count -gt 0) { + $rendered.Add(($parts -join ' ')) + } else { + # Nothing we recognize — dump JSON so the user still sees something useful. + # Serialization can fail for objects with circular refs or COM types; in that case + # we drop the entry silently (the top-level message will still surface). + try { + $rendered.Add(($e | ConvertTo-Json -Compress -Depth 5)) + } catch { + Write-Verbose "Format-InforcerErrorDetail: skipping unserializable entry ($($_.Exception.Message))" + } + } + } + + if ($rendered.Count -eq 0) { return $null } + return ($rendered -join '; ') +} diff --git a/module/Private/Get-InforcerComparisonData.ps1 b/module/Private/Get-InforcerComparisonData.ps1 index 2d3c498..ae2c66d 100644 --- a/module/Private/Get-InforcerComparisonData.ps1 +++ b/module/Private/Get-InforcerComparisonData.ps1 @@ -204,7 +204,7 @@ function Get-InforcerComparisonData { } $scriptData[$propName] = $val } - $scriptJson = $scriptData | ConvertTo-Json -Depth 5 -Compress + $scriptJson = $scriptData | ConvertTo-Json -Depth 100 -Compress $policy.policyData | Add-Member -NotePropertyName 'linkedComplianceScript' -NotePropertyValue $scriptJson -Force $scriptPolicy | Add-Member -NotePropertyName '_claimedByCompliancePolicy' -NotePropertyValue $true -Force } diff --git a/module/Private/Get-InforcerHeaderValue.ps1 b/module/Private/Get-InforcerHeaderValue.ps1 new file mode 100644 index 0000000..c267782 --- /dev/null +++ b/module/Private/Get-InforcerHeaderValue.ps1 @@ -0,0 +1,66 @@ +function Get-InforcerHeaderValue { + <# + .SYNOPSIS + Case-insensitive lookup of a single response header value (Private helper). + .DESCRIPTION + Header collection shapes vary across PowerShell hosts: + * Invoke-RestMethod -ResponseHeadersVariable returns IDictionary + * Exception.Response.Headers / Invoke-WebRequest.Headers may be HttpResponseHeaders, + WebHeaderCollection, or a plain hashtable + + Returns the first matching value as a string, or $null when absent. + .PARAMETER Headers + The header collection object. Accepts any of the shapes above. $null returns $null. + .PARAMETER Name + The header name (case-insensitive). + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $false, Position = 0)] + $Headers, + + [Parameter(Mandatory = $true, Position = 1)] + [string]$Name + ) + + if ($null -eq $Headers) { return $null } + + if ($Headers -is [System.Net.Http.Headers.HttpResponseHeaders] -or ` + $Headers -is [System.Net.Http.Headers.HttpHeaders]) { + $values = $null + if ($Headers.TryGetValues($Name, [ref]$values)) { + return (($values | Select-Object -First 1) -as [string]) + } + return $null + } + + if ($Headers -is [System.Net.WebHeaderCollection]) { + $value = $Headers[$Name] + if ($value) { return ($value -as [string]) } + return $null + } + + if ($Headers -is [System.Collections.IDictionary]) { + foreach ($key in $Headers.Keys) { + if (($key -as [string]) -ieq $Name) { + $value = $Headers[$key] + if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { + return (($value | Select-Object -First 1) -as [string]) + } + return ($value -as [string]) + } + } + return $null + } + + if ($Headers.PSObject.Properties[$Name]) { + $value = $Headers.PSObject.Properties[$Name].Value + if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { + return (($value | Select-Object -First 1) -as [string]) + } + return ($value -as [string]) + } + + return $null +} diff --git a/module/Private/Get-InforcerReportTypeStaticKeys.ps1 b/module/Private/Get-InforcerReportTypeStaticKeys.ps1 new file mode 100644 index 0000000..700f674 --- /dev/null +++ b/module/Private/Get-InforcerReportTypeStaticKeys.ps1 @@ -0,0 +1,51 @@ +function Get-InforcerReportTypeStaticKeys { + <# + .SYNOPSIS + Returns the static fallback list of Reports API type keys used by argument completers + before the live catalog cache ($script:InforcerReportTypeCache) is primed. + .DESCRIPTION + Argument completers on -ReportType / -Key prefer the live catalog when populated, but + until the first /beta/reports/types call this static list is what shows up at TAB. + Centralised here so Get-InforcerReportType and Invoke-InforcerReport stay in lockstep — + empirically matched to the production catalog observed against api.inforcer.com. + .OUTPUTS + System.String[] + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', + Justification = 'Returns a collection of keys; plural noun is intentional.')] + [CmdletBinding()] + [OutputType([string[]])] + param() + + ,$script:InforcerReportTypeStaticKeys +} + +# Module-scoped canonical list — both completers read this directly so a single edit keeps +# Get-InforcerReportType and Invoke-InforcerReport completion identical. +$script:InforcerReportTypeStaticKeys = @( + 'ActiveUserCount' + 'ActiveUsers' + 'Assessment' + 'CopilotAdoption' + 'CredentialUserRegistrationDetails' + 'EncryptionDisabledDevices' + 'EncryptionEnabledDevices' + 'GetDetectedRisks' + 'GetDetectedServicePrincipalRisks' + 'GetOneDriveUsageAccountCounts' + 'GetOneDriveUsageStorage' + 'GetRiskyServicePrincipals' + 'GetRiskyUsers' + 'GetSharePointSiteUsageDetail' + 'GetSharePointSiteUsageStorage' + 'GetTenantMFAReport' + 'GlobalAdmins' + 'NonCompliantDevices' + 'SecureScores' + 'SecureScoresAverageComparativeScores' + 'SecureScoresControlScores' + 'ShadowAiDetection' + 'SubscribedSkus' + 'TenantAuditReport' + 'TenantMfaReport' +) diff --git a/module/Private/Get-InforcerSettingsCatalogPath.ps1 b/module/Private/Get-InforcerSettingsCatalogPath.ps1 index edecbbb..837eafd 100644 --- a/module/Private/Get-InforcerSettingsCatalogPath.ps1 +++ b/module/Private/Get-InforcerSettingsCatalogPath.ps1 @@ -107,7 +107,7 @@ function Get-InforcerSettingsCatalogPath { if ($cacheMeta.releaseTimestamp -eq $remoteInfo.updatedAt) { # Remote unchanged -- refresh lastChecked and use cache $cacheMeta.lastChecked = [datetime]::UtcNow.ToString('o') - $cacheMeta | ConvertTo-Json | Set-Content -Path $metaPath -Encoding UTF8 + $cacheMeta | ConvertTo-Json -Depth 100 | Set-Content -Path $metaPath -Encoding UTF8 Write-Verbose 'Remote data unchanged -- refreshed cache TTL' return $settingsPath } @@ -157,7 +157,7 @@ function Get-InforcerSettingsCatalogPath { releaseTimestamp = $(if ($remoteInfo) { $remoteInfo.updatedAt } else { [datetime]::UtcNow.ToString('o') }) schemaVersion = $(if ($remoteInfo) { $remoteInfo.schemaVersion } else { 1 }) } - $newMeta | ConvertTo-Json | Set-Content -Path $metaPath -Encoding UTF8 + $newMeta | ConvertTo-Json -Depth 100 | Set-Content -Path $metaPath -Encoding UTF8 Write-Host ' Settings Catalog data cached successfully.' -ForegroundColor Green return $settingsPath } diff --git a/module/Private/Invoke-InforcerApiRequest.ps1 b/module/Private/Invoke-InforcerApiRequest.ps1 index 6db69f8..25ba153 100644 --- a/module/Private/Invoke-InforcerApiRequest.ps1 +++ b/module/Private/Invoke-InforcerApiRequest.ps1 @@ -5,6 +5,15 @@ function Invoke-InforcerApiRequest { .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: + * 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) + + 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. .PARAMETER Endpoint API path (e.g. /beta/tenants). Leading slash optional. .PARAMETER Method @@ -76,10 +85,12 @@ function Invoke-InforcerApiRequest { 'Content-Type' = 'application/json' } + $responseHeaders = $null $params = @{ - Uri = $uri - Method = $Method - Headers = $headers + Uri = $uri + Method = $Method + Headers = $headers + ResponseHeadersVariable = 'responseHeaders' } if (-not [string]::IsNullOrWhiteSpace($Body)) { $params['Body'] = $Body @@ -87,59 +98,108 @@ function Invoke-InforcerApiRequest { try { $rawResponse = Invoke-RestMethod @params + $correlationId = Get-InforcerHeaderValue -Headers $responseHeaders -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' } - # PS7: ErrorDetails.Message contains the response body + # Response body: PS7 surfaces it via ErrorDetails.Message; PS5.1 needs a stream read. + $responseBody = $null if ($_.ErrorDetails.Message) { - $detail = $_.ErrorDetails.Message - try { - $json = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($json) { - $errorCode = ($json.PSObject.Properties['errorCode'].Value -as [string]) - $apiMessage = ($json.PSObject.Properties['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.error) { $json.error } else { $detail } - } - } - } - } catch { } - } - # PS5.1 fallback: read from response stream - elseif ($_.Exception.Response) { + $responseBody = $_.ErrorDetails.Message + } elseif ($_.Exception.Response) { $reader = $null try { $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) $responseBody = $reader.ReadToEnd() - $detail = $responseBody - try { - $json = $responseBody | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($json.message) { $detail = $json.message } - elseif ($json.error) { $detail = $json.error } - } catch { } - } finally { + } catch { } finally { if ($reader) { $reader.Dispose() } } } - $apiKeyPattern = [regex]::new([regex]::Escape($apiKey), 'Compiled') - $detail = $apiKeyPattern.Replace($detail, '[REDACTED]') + if (-not [string]::IsNullOrWhiteSpace($responseBody)) { + $detail = $responseBody + try { + $json = $responseBody | ConvertFrom-Json -ErrorAction SilentlyContinue + } 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" + } + } + } + } 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]) + } + + $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 } + } + } + + 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" } + if ($correlationId) { + $msg = "$msg [correlation-id: $correlationId]" + } $errorId = if ($statusCode -gt 0) { "ApiRequestFailed_$statusCode" } else { 'ApiRequestFailed' } Write-Error -Message $msg -ErrorId $errorId -Category ConnectionError return diff --git a/module/Private/Invoke-InforcerAssessmentRun.ps1 b/module/Private/Invoke-InforcerAssessmentRun.ps1 index fa8afce..c6893bb 100644 --- a/module/Private/Invoke-InforcerAssessmentRun.ps1 +++ b/module/Private/Invoke-InforcerAssessmentRun.ps1 @@ -38,38 +38,49 @@ function Invoke-InforcerAssessmentRun { $null = $ps.AddParameter('Headers', $headers) $asyncResult = $ps.BeginInvoke() - $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() - $lastUpdate = 0 - while (-not $asyncResult.IsCompleted) { - Start-Sleep -Milliseconds 500 - $elapsed = [math]::Floor($stopwatch.Elapsed.TotalSeconds) - if ($elapsed -gt 0 -and $elapsed % 10 -eq 0 -and $elapsed -ne $lastUpdate) { - Write-Host " Still running... $(& $formatElapsed $elapsed) elapsed" - $lastUpdate = $elapsed - } - } - $rawResponse = $null $hasError = $false + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() try { - $rawResponse = $ps.EndInvoke($asyncResult) - 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) + '...' } - Write-Error -Message $errMsg -ErrorId 'AssessmentRunFailed' -Category InvalidResult + # Poll for completion. Ctrl+C during Start-Sleep raises PipelineStoppedException; the + # outer finally still runs and disposes the runspace, so we don't leak the underlying + # HTTP socket. + $lastUpdate = 0 + while (-not $asyncResult.IsCompleted) { + Start-Sleep -Milliseconds 500 + $elapsed = [math]::Floor($stopwatch.Elapsed.TotalSeconds) + if ($elapsed -gt 0 -and $elapsed % 10 -eq 0 -and $elapsed -ne $lastUpdate) { + Write-Host " Still running... $(& $formatElapsed $elapsed) elapsed" + $lastUpdate = $elapsed } } - } 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) + '...' } - Write-Error -Message "Assessment run failed: $errMsg" -ErrorId 'AssessmentRunFailed' -Category InvalidResult + try { + $rawResponse = $ps.EndInvoke($asyncResult) + 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) + '...' } + 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) + '...' } + Write-Error -Message "Assessment run failed: $errMsg" -ErrorId 'AssessmentRunFailed' -Category InvalidResult + } } finally { - $ps.Dispose() + # Cancel any in-flight pipeline (no-op if it already completed) and dispose the + # runspace. This block also runs when the user Ctrl+C's mid-poll, preventing the + # runspace + socket leak that the previous structure (Dispose only inside the + # post-loop try/finally) allowed. + if ($null -ne $ps) { + try { if ($ps.InvocationStateInfo.State -eq 'Running') { $ps.Stop() } } catch { $null } + $ps.Dispose() + } } $stopwatch.Stop() diff --git a/module/Private/Invoke-InforcerRawDownload.ps1 b/module/Private/Invoke-InforcerRawDownload.ps1 new file mode 100644 index 0000000..1940f1d --- /dev/null +++ b/module/Private/Invoke-InforcerRawDownload.ps1 @@ -0,0 +1,205 @@ +function Invoke-InforcerRawDownload { + <# + .SYNOPSIS + Performs a binary-safe GET against an Inforcer API endpoint (Private helper). + .DESCRIPTION + Uses Invoke-WebRequest under the hood so the response body is captured as raw bytes + 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. + + 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. + .PARAMETER DefaultFileName + Suggested filename when the server doesn't return a usable Content-Disposition. + Defaults to 'output'. + .PARAMETER DestinationDirectory + When set, response body is streamed directly to a temp file in this directory rather + than buffered into memory, then renamed to the Content-Disposition filename. Use this + for large downloads (>100MB) to avoid a 2GB+ in-memory allocation on multi-GB reports. + The returned object has a `FilePath` instead of `Bytes`. The directory must already exist. + .OUTPUTS + PSCustomObject with members: + Bytes — [byte[]] response body (omitted when -DestinationDirectory is used) + FilePath — final on-disk path (only when -DestinationDirectory is used) + FileName — sanitized filename (from Content-Disposition or DefaultFileName) + ContentType — value of Content-Type header (or $null) + CorrelationId — x-correlation-id of the response (or $null) + StatusCode — int HTTP status code + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [string]$Endpoint, + + [Parameter(Mandatory = $false)] + [string]$DefaultFileName = 'output', + + [Parameter(Mandatory = $false)] + [string]$DestinationDirectory + ) + + if (-not (Test-InforcerSession)) { + Write-Error -Message 'Not connected. Run Connect-Inforcer first.' ` + -ErrorId 'NotConnected' -Category ConnectionError + 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' = '*/*' + } + + Write-Verbose "Downloading: GET $uri" + + # When -DestinationDirectory is set, stream the body straight to a temp file via + # `-OutFile` so we don't materialize a multi-GB body in memory. The body is renamed + # to the Content-Disposition filename after the response headers are inspected. + $tempStreamPath = $null + if ($PSBoundParameters.ContainsKey('DestinationDirectory')) { + if (-not (Test-Path -LiteralPath $DestinationDirectory -PathType Container)) { + Write-Error -Message "DestinationDirectory '$DestinationDirectory' does not exist." ` + -ErrorId 'BadDestination' -Category InvalidArgument + return + } + $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 + } + } catch { + if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) { + Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue + } + $msg = Protect-InforcerApiKeyInText -Text $_.Exception.Message -ApiKey $apiKey + Write-Error -Message "Download request failed: $msg" ` + -ErrorId 'DownloadFailed' -Category ConnectionError + return + } + + $statusCode = [int]$response.StatusCode + $correlationId = Get-InforcerHeaderValue -Headers $response.Headers -Name 'x-correlation-id' + if ($correlationId) { + Write-Verbose "x-correlation-id: $correlationId" + } + + if ($statusCode -lt 200 -or $statusCode -ge 300) { + # Parse error body for a friendly message — same envelope shapes as Invoke-InforcerApiRequest. + # If we streamed to disk, the error body is in the temp file, not $response.Content. + $detail = $null + $bodyText = $null + if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) { + try { $bodyText = Get-Content -LiteralPath $tempStreamPath -Raw -ErrorAction Stop } catch { $bodyText = $null } + Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue + } elseif ($response.Content) { + $bodyText = if ($response.Content -is [byte[]]) { + [System.Text.Encoding]::UTF8.GetString($response.Content) + } else { + $response.Content -as [string] + } + } + if ($bodyText) { + $detail = $bodyText + try { + $json = $bodyText | ConvertFrom-Json -ErrorAction Stop + $apiMessage = $null + $traceId = $null + if ($null -ne $json.PSObject.Properties['message']) { + $apiMessage = $json.PSObject.Properties['message'].Value -as [string] + } elseif ($null -ne $json.PSObject.Properties['title']) { + $apiMessage = $json.PSObject.Properties['title'].Value -as [string] + if ($null -ne $json.PSObject.Properties['traceId']) { + $traceId = $json.PSObject.Properties['traceId'].Value -as [string] + } + } + if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $detail = $apiMessage } + if (-not [string]::IsNullOrWhiteSpace($traceId)) { $detail = "$detail (traceId: $traceId)" } + } catch { + Write-Verbose 'Error response body was not JSON; using raw content.' + } + } + if ([string]::IsNullOrWhiteSpace($detail)) { $detail = "HTTP $statusCode" } + $detail = Protect-InforcerApiKeyInText -Text $detail -ApiKey $apiKey + $msg = "Inforcer API download failed (HTTP $statusCode): $detail" + if ($correlationId) { $msg = "$msg [correlation-id: $correlationId]" } + Write-Error -Message $msg -ErrorId "DownloadFailed_$statusCode" -Category ConnectionError + return + } + + # Extract Content-Disposition / Content-Type header values (cross-shape tolerant). + $disposition = $null + $contentType = $null + if ($response.Headers) { + $disposition = Get-InforcerHeaderValue -Headers $response.Headers -Name 'Content-Disposition' + $contentType = Get-InforcerHeaderValue -Headers $response.Headers -Name 'Content-Type' + } + + $fileName = Resolve-InforcerReportOutputFileName -ContentDisposition $disposition -DefaultName $DefaultFileName + + if ($tempStreamPath) { + # Streaming mode: rename the temp file to the final Content-Disposition name. Move-Item + # is atomic within the same volume; if the destination already exists, Force overwrites it. + $finalPath = Join-Path -Path $DestinationDirectory -ChildPath $fileName + try { + Move-Item -LiteralPath $tempStreamPath -Destination $finalPath -Force -ErrorAction Stop + } catch { + Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue + Write-Error -Message "Failed to finalize downloaded file at '$finalPath': $($_.Exception.Message)" ` + -ErrorId 'DownloadFinalizeFailed' -Category WriteError + return + } + $size = (Get-Item -LiteralPath $finalPath -ErrorAction SilentlyContinue).Length + return [PSCustomObject]@{ + FilePath = $finalPath + FileName = $fileName + FileSize = $size + ContentType = $contentType + CorrelationId = $correlationId + StatusCode = $statusCode + } + } + + # In-memory mode (existing behavior — backwards compatible for small payloads). + # Invoke-WebRequest returns Content as byte[] when -UseBasicParsing and binary response. + $bytes = if ($response.Content -is [byte[]]) { + $response.Content + } elseif ($response.Content -is [string]) { + [System.Text.Encoding]::UTF8.GetBytes($response.Content) + } else { + @() + } + + [PSCustomObject]@{ + Bytes = $bytes + FileName = $fileName + ContentType = $contentType + CorrelationId = $correlationId + StatusCode = $statusCode + } +} diff --git a/module/Private/Protect-InforcerApiKeyInText.ps1 b/module/Private/Protect-InforcerApiKeyInText.ps1 new file mode 100644 index 0000000..fa4aaa7 --- /dev/null +++ b/module/Private/Protect-InforcerApiKeyInText.ps1 @@ -0,0 +1,29 @@ +function Protect-InforcerApiKeyInText { + <# + .SYNOPSIS + Replaces every occurrence of the live API key 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. + .PARAMETER Text + The input text — may contain the API key anywhere. + .PARAMETER ApiKey + The plain-text API key to redact. If $null/empty, returns the input unchanged. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $false, Position = 0)] + [string]$Text, + + [Parameter(Mandatory = $true, Position = 1)] + [string]$ApiKey + ) + + if ([string]::IsNullOrWhiteSpace($Text) -or [string]::IsNullOrWhiteSpace($ApiKey)) { + return $Text + } + # 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]') +} diff --git a/module/Private/Resolve-InforcerGraphEnrichment.ps1 b/module/Private/Resolve-InforcerGraphEnrichment.ps1 index 7d0de24..514d8f1 100644 --- a/module/Private/Resolve-InforcerGraphEnrichment.ps1 +++ b/module/Private/Resolve-InforcerGraphEnrichment.ps1 @@ -76,7 +76,7 @@ function Resolve-InforcerGraphEnrichment { # Use batch endpoint to resolve all IDs in one call (max 1000 per request) $idList = @($objectIds) try { - $body = @{ ids = $idList; types = @('group','user','device','servicePrincipal') } | ConvertTo-Json -Depth 10 -Compress + $body = @{ ids = $idList; types = @('group','user','device','servicePrincipal') } | ConvertTo-Json -Depth 100 -Compress $response = Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/v1.0/directoryObjects/getByIds' ` -Method POST -Body $body -ContentType 'application/json' -OutputType PSObject -ErrorAction Stop if ($response -and $response.value) { diff --git a/module/Private/Resolve-InforcerReportOutputFileName.ps1 b/module/Private/Resolve-InforcerReportOutputFileName.ps1 new file mode 100644 index 0000000..4fd047c --- /dev/null +++ b/module/Private/Resolve-InforcerReportOutputFileName.ps1 @@ -0,0 +1,138 @@ +function Resolve-InforcerReportOutputFileName { + <# + .SYNOPSIS + Parses a Content-Disposition header to extract a safe output filename (Private helper). + .DESCRIPTION + Handles both filename= and the RFC 5987 filename*=charset'lang'percent-encoded form, + preferring the latter when present (it can carry non-ASCII characters that filename= + cannot). The returned name is sanitized: path components stripped, reserved characters + removed, leading/trailing whitespace trimmed. + + When the header is absent, empty, or contains no usable filename, falls back to + -DefaultName (or 'output' if also unset). + .PARAMETER ContentDisposition + The raw Content-Disposition header value. May be $null/empty. + .PARAMETER DefaultName + Fallback name when no usable filename can be parsed. Default: 'output'. + .OUTPUTS + String — a sanitized filename safe for cross-platform filesystem use. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $false, Position = 0)] + [string]$ContentDisposition, + + [Parameter(Mandatory = $false)] + [string]$DefaultName = 'output' + ) + + $parsed = $null + + if (-not [string]::IsNullOrWhiteSpace($ContentDisposition)) { + # RFC 5987 form takes precedence (supports non-ASCII). + # Grammar: filename*='' + # We accept any charset; default to UTF-8 when missing or unknown. + $rfc5987 = [regex]::Match( + $ContentDisposition, + "filename\*\s*=\s*(?[^']*)'(?[^']*)'(?[^;]+)", + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) + if ($rfc5987.Success) { + $charsetName = $rfc5987.Groups['charset'].Value + $encodedValue = $rfc5987.Groups['value'].Value.Trim() + $encoding = [System.Text.Encoding]::UTF8 + if (-not [string]::IsNullOrWhiteSpace($charsetName)) { + try { + $encoding = [System.Text.Encoding]::GetEncoding($charsetName) + } catch { + $encoding = [System.Text.Encoding]::UTF8 + } + } + try { + $parsed = [System.Web.HttpUtility]::UrlDecode($encodedValue, $encoding) + } catch { + $parsed = [System.Uri]::UnescapeDataString($encodedValue) + } + } + + # Fallback to plain filename= + if ([string]::IsNullOrWhiteSpace($parsed)) { + $plain = [regex]::Match( + $ContentDisposition, + 'filename\s*=\s*(?"(?[^"]*)"|(?[^;]+))', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) + if ($plain.Success) { + $value = if ($plain.Groups['q'].Success) { $plain.Groups['q'].Value } else { $plain.Groups['raw'].Value } + $parsed = $value.Trim() + } + } + } + + # Guarantee we have *something* even if both ContentDisposition and DefaultName are unusable. + if ([string]::IsNullOrWhiteSpace($parsed)) { $parsed = $DefaultName } + if ([string]::IsNullOrWhiteSpace($parsed)) { $parsed = 'output' } + + # Strip any directory components — only keep the leaf name. + $parsed = [System.IO.Path]::GetFileName($parsed) + + # Replace control + cross-platform-unsafe characters with underscore. Applied on every + # platform (defense-in-depth so files written on macOS / Linux are safely copied to a + # Windows host later). The set is the Windows-reserved character class plus 0x00-0x1F + # control chars. + $unsafe = '[<>:"|?*\\/\x00-\x1F]' + $parsed = [regex]::Replace($parsed, $unsafe, '_') + + # Trim trailing dots and spaces (illegal on Windows). Avoid stripping the only dot + # in the name — "report." (which would otherwise eat the extension separator) is + # preserved when the name has actual non-dot content. + $beforeTrim = $parsed + $candidate = $parsed.Trim().TrimEnd('.', ' ') + $beforeTrimStripped = $beforeTrim.Trim().Trim('.', ' ') + if ($candidate.Length -eq 0) { + # Path was entirely dots/spaces — fall through to default. + $parsed = '' + } elseif (-not $candidate.Contains('.') -and $beforeTrimStripped.Length -gt 0 -and $beforeTrim.Trim() -ne $candidate) { + # Trim removed a meaningful trailing dot. Keep it. + $parsed = $beforeTrim.Trim() + } else { + $parsed = $candidate + } + + if ([string]::IsNullOrWhiteSpace($parsed)) { $parsed = 'output' } + + # Prefix Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9 with or without + # extension) to avoid OS-level redirection on Windows. Cross-platform safe; just a leading '_'. + $stem = [System.IO.Path]::GetFileNameWithoutExtension($parsed) + $reserved = @('CON','PRN','AUX','NUL','COM1','COM2','COM3','COM4','COM5','COM6','COM7','COM8','COM9', + 'LPT1','LPT2','LPT3','LPT4','LPT5','LPT6','LPT7','LPT8','LPT9') + if ($reserved -contains $stem.ToUpperInvariant()) { + $parsed = '_' + $parsed + } + + # Cap leaf length at 200 chars (HFS+/APFS allows 255 bytes; multibyte names hit that fast). + # Preserve the extension. Walk back from the cut point to a grapheme-cluster boundary so a + # combining mark isn't orphaned from its base char. + if ($parsed.Length -gt 200) { + $ext = [System.IO.Path]::GetExtension($parsed) + $base = [System.IO.Path]::GetFileNameWithoutExtension($parsed) + $maxBase = 200 - $ext.Length + if ($maxBase -lt 1) { $maxBase = 1 } + $cut = [Math]::Min($base.Length, $maxBase) + # Pull the cut back until it sits on a grapheme cluster boundary (handles combining marks). + if ($cut -lt $base.Length) { + $elements = [System.Globalization.StringInfo]::ParseCombiningCharacters($base) + $safe = $cut + foreach ($e in $elements) { + if ($e -ge $cut) { break } + $safe = $e + } + $cut = $safe + if ($cut -lt 1) { $cut = 1 } + } + $parsed = $base.Substring(0, $cut) + $ext + } + + return $parsed +} diff --git a/module/Private/Resolve-InforcerReportTypeSchema.ps1 b/module/Private/Resolve-InforcerReportTypeSchema.ps1 new file mode 100644 index 0000000..ac181d2 --- /dev/null +++ b/module/Private/Resolve-InforcerReportTypeSchema.ps1 @@ -0,0 +1,296 @@ +function Resolve-InforcerReportTypeSchema { + <# + .SYNOPSIS + Validates a (ReportType, OutputFormat, parameters) tuple against the report types catalog + 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. + + Validates client-side (so the user gets a clear error before the API does): + * ReportType is in the catalog (case-insensitive) + * OutputFormat is in the type's supportedFormats (when discoverable) + * Collate switch is rejected on types where the catalog reports collatable:false + * AssessmentId is mandatory for the Assessment report type + * Smart defaults applied: CopilotAdoption / ShadowAiDetection → report-period=30 when omitted + + On invalid input, throws a terminating error. The catalog response shape isn't fully + contractual yet — the helper introspects common property name variants and degrades to + "skip validation, defer to server" when the shape isn't recognized. + .PARAMETER ReportType + Single report type key (e.g. 'CopilotAdoption'). Case-insensitive. + .PARAMETER OutputFormat + Single output format (e.g. 'csv', 'pdf'). Case-insensitive. + .PARAMETER Parameter + Optional hashtable of additional parameters passed straight to the API. + .PARAMETER ReportPeriod + Optional integer days. When set, merged into parameters as 'report-period'=. + .PARAMETER AssessmentId + Optional opaque alphanumeric string (NOT a GUID — e.g. 'l1f8wd29pl44pp1j66r9'). When + set, merged into parameters as 'assessment-id'=. Required for + ReportType='Assessment'. + .PARAMETER Collate + Request a single cross-tenant output. Only valid on types with collatable:true. + .PARAMETER Force + Bypass the cache and refetch the catalog. + .OUTPUTS + PSCustomObject with members: + Entry — the [ordered]@{...} hashtable ready to drop into reports[] array + TypeKey — canonical type key + OutputFormat — canonical output format + Collate — boolean + Parameters — final flattened parameters hashtable + CatalogEntry — the matching catalog item (or $null when catalog unavailable) + IsCollatable — boolean / $null + SupportedFormats — list of supported formats (or $null) + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [string]$ReportType, + + [Parameter(Mandatory = $true)] + [string]$OutputFormat, + + [Parameter(Mandatory = $false)] + [hashtable]$Parameter, + + [Parameter(Mandatory = $false)] + [Nullable[int]]$ReportPeriod, + + [Parameter(Mandatory = $false)] + [string]$AssessmentId, + + [Parameter(Mandatory = $false)] + [switch]$Collate, + + [Parameter(Mandatory = $false)] + [switch]$Force + ) + + # 1. Ensure catalog is available. Cache lives for 15 minutes — long-running sessions + # auto-refresh so newly-shipped report types don't require disconnect/reconnect. + $catalogFetchError = $null + $cacheTtlMinutes = 15 + $cacheIsStale = $false + if ($script:InforcerReportTypeCache -and $script:InforcerReportTypeCacheStamp) { + $age = (Get-Date) - $script:InforcerReportTypeCacheStamp + if ($age.TotalMinutes -gt $cacheTtlMinutes) { + Write-Verbose ("Reports catalog cache is {0:N1} minutes old (TTL {1}m) — refreshing." -f $age.TotalMinutes, $cacheTtlMinutes) + $cacheIsStale = $true + } + } + if ($Force -or $null -eq $script:InforcerReportTypeCache -or $cacheIsStale) { + try { + $catalog = Invoke-InforcerApiRequest -Endpoint '/beta/reports/types' -Method GET -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 } + } + if ($catalog) { + $script:InforcerReportTypeCache = @($catalog) + $script:InforcerReportTypeCacheStamp = Get-Date + } + } + $catalog = $script:InforcerReportTypeCache + + # If catalog fetch failed (vs. genuinely-empty endpoint), surface a single warning so + # callers know their input wasn't validated client-side. Without this, a transient + # network failure / rate limit / scope issue would let bogus -ReportType / -OutputFormat + # / unknown -Parameter keys pass through to the API as confusing 400s. + if ($null -eq $catalog -and $null -ne $catalogFetchError) { + Write-Warning ("Report types catalog could not be fetched ({0}). Client-side validation of -ReportType / -OutputFormat / -Parameter is disabled for this call; errors will surface from the server." -f $catalogFetchError) + } + + # 2. Look up the type entry (or proceed without when catalog isn't available) + $typeEntry = $null + if ($catalog) { + $typeEntry = $catalog | Where-Object { + $keyProp = $_.PSObject.Properties['key'] + $keyProp -and (($keyProp.Value -as [string]) -ieq $ReportType) + } | Select-Object -First 1 + + if (-not $typeEntry -and -not $Force) { + # Cache may be stale (Inforcer ships new report types regularly). Refetch once before + # 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 + } catch { + $refreshed = $null + } + if ($refreshed) { + $script:InforcerReportTypeCache = @($refreshed) + $catalog = $script:InforcerReportTypeCache + $typeEntry = $catalog | Where-Object { + $keyProp = $_.PSObject.Properties['key'] + $keyProp -and (($keyProp.Value -as [string]) -ieq $ReportType) + } | Select-Object -First 1 + } + } + + if (-not $typeEntry) { + $availableKeys = @($catalog | ForEach-Object { $_.PSObject.Properties['key'].Value -as [string] } | Where-Object { $_ }) -join ', ' + throw "Unknown report type '$ReportType'. Available: $availableKeys" + } + } else { + Write-Verbose "Report types catalog unavailable; skipping catalog validation for '$ReportType'." + } + + # 3. Canonicalize ReportType / OutputFormat using catalog casing when present + if ($typeEntry -and $typeEntry.PSObject.Properties['key']) { + $ReportType = ($typeEntry.PSObject.Properties['key'].Value -as [string]) + } + + # 4. Validate OutputFormat against catalog (when discoverable) + $supportedFormats = $null + if ($typeEntry) { + foreach ($candidate in 'supportedOutputFormats','outputFormats','supportedFormats','formats') { + if ($typeEntry.PSObject.Properties[$candidate]) { + $supportedFormats = $typeEntry.PSObject.Properties[$candidate].Value + break + } + } + } + if ($supportedFormats) { + $matchedFormat = $null + foreach ($candidate in @($supportedFormats)) { + if (($candidate -as [string]) -ieq $OutputFormat) { $matchedFormat = $candidate -as [string]; break } + } + if (-not $matchedFormat) { + $list = ($supportedFormats -join ', ') + throw "Report type '$ReportType' does not support output format '$OutputFormat'. Supported: $list" + } + $OutputFormat = $matchedFormat + } + + # 5. Validate Collate against catalog + $isCollatable = $null + if ($typeEntry -and $typeEntry.PSObject.Properties['collatable']) { + $isCollatable = [bool]$typeEntry.PSObject.Properties['collatable'].Value + } + if ($Collate.IsPresent -and $null -ne $isCollatable -and -not $isCollatable) { + throw "Report type '$ReportType' does not support collation (collatable:false). Remove -Collate or choose a different type." + } + + # 6. Build the final flattened parameters bag (string values only — matches API contract). + # Reject non-scalar values up-front — passing an array or hashtable would otherwise get + # silently coerced via `-as [string]` ("System.Object[]" or "1 2 3"), producing a request + # the server can't act on without any clear error. + $finalParams = @{} + if ($Parameter) { + foreach ($k in $Parameter.Keys) { + $v = $Parameter[$k] + # Scriptblocks would silently stringify to "{...}" via `-as [string]` — reject explicitly + # so the user finds out their callable wasn't sent as a value. + if ($v -is [scriptblock]) { + throw "Parameter '$k' is a [scriptblock]; the API only accepts scalar string-coercible values. Did you mean to invoke the scriptblock first?" + } + # Arrays / lists / dictionaries silently flatten to "1 2 3" or "System.Object[]". + # Exclude [string] (IEnumerable) and [DateTime] (PowerShell decorates date types + # with PSCustomObject markers in some hosts — keep them scalar). + if ($null -ne $v -and $v -isnot [string] -and $v -isnot [datetime] -and $v -is [System.Collections.IEnumerable]) { + throw "Parameter '$k' must be a scalar value; arrays and collections are not supported by the API. Got: $($v.GetType().FullName)." + } + # Use the literal PSCustomObject type — `-is [PSCustomObject]` is too broad in PS7 and + # incorrectly matches scalars like [DateTime]. + if ($v -is [hashtable] -or $v -is [System.Management.Automation.PSCustomObject]) { + throw "Parameter '$k' must be a scalar value; got nested object of type $($v.GetType().FullName)." + } + $finalParams[($k -as [string])] = ($v -as [string]) + } + } + if ($PSBoundParameters.ContainsKey('ReportPeriod')) { + $finalParams['report-period'] = ($ReportPeriod -as [string]) + } + if ($PSBoundParameters.ContainsKey('AssessmentId')) { + # Public AssessmentId has no [ValidateNotNullOrEmpty]; an empty value would otherwise + # bypass the Assessment-requires-id check below and reach the server with assessment-id='' + # producing a confusing "invalid assessment" failure instead of a clear local error. + if ([string]::IsNullOrWhiteSpace($AssessmentId)) { + throw "AssessmentId was provided but is empty. Provide a non-empty -AssessmentId value (run Get-InforcerAssessment to list available IDs)." + } + $finalParams['assessment-id'] = $AssessmentId + } + + # 7. Validate parameter keys against catalog (defensive — bug #6: server silently ignores unknowns) + if ($typeEntry -and $finalParams.Count -gt 0) { + $catalogParams = $null + foreach ($candidate in 'requiredParameters','parameters','params') { + if ($typeEntry.PSObject.Properties[$candidate]) { + $catalogParams = $typeEntry.PSObject.Properties[$candidate].Value + break + } + } + if ($catalogParams) { + $allowedKeys = @( + foreach ($p in @($catalogParams)) { + if ($p -is [string]) { + $p + } else { + foreach ($keyProp in 'key','name','id') { + if ($p.PSObject.Properties[$keyProp]) { + $p.PSObject.Properties[$keyProp].Value -as [string] + break + } + } + } + } + ) | Where-Object { $_ } + + if ($allowedKeys.Count -gt 0) { + $allowedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($a in $allowedKeys) { [void]$allowedSet.Add($a) } + foreach ($k in @($finalParams.Keys)) { + if (-not $allowedSet.Contains($k)) { + $allowedList = ($allowedKeys -join ', ') + throw "Unknown parameter '$k' for report type '$ReportType'. Allowed: $allowedList" + } + } + } + } + } + + # 8. Apply smart auto-defaults for known types + if (-not $finalParams.ContainsKey('report-period')) { + if ($ReportType -ieq 'CopilotAdoption' -or $ReportType -ieq 'ShadowAiDetection') { + $finalParams['report-period'] = '30' + } + } + + # 9. Assessment type requires assessment-id with no sensible default + if ($ReportType -ieq 'Assessment' -and -not $finalParams.ContainsKey('assessment-id')) { + throw "Report type 'Assessment' requires -AssessmentId. Run Get-InforcerAssessment to list available IDs." + } + + # 10. Build the per-request entry shape that Invoke-InforcerReport will drop into reports[] + $entry = [ordered]@{ + type = $ReportType + outputFormat = $OutputFormat + } + if ($Collate.IsPresent) { + $entry['collate'] = $true + } + if ($finalParams.Count -gt 0) { + $entry['parameters'] = $finalParams + } + + [PSCustomObject]@{ + Entry = $entry + TypeKey = $ReportType + OutputFormat = $OutputFormat + Collate = [bool]$Collate.IsPresent + Parameters = $finalParams + CatalogEntry = $typeEntry + IsCollatable = $isCollatable + SupportedFormats = $supportedFormats + } +} diff --git a/module/Private/Resolve-InforcerTenantId.ps1 b/module/Private/Resolve-InforcerTenantId.ps1 index ba745cd..712082a 100644 --- a/module/Private/Resolve-InforcerTenantId.ps1 +++ b/module/Private/Resolve-InforcerTenantId.ps1 @@ -82,8 +82,13 @@ function Resolve-InforcerTenantId { } if ($foundTenants.Count -gt 1) { - $ids = ($foundTenants | ForEach-Object { $_.PSObject.Properties['clientTenantId'].Value }) -join ', ' - throw [System.InvalidOperationException]::new("Multiple tenants match name '$tenantIdString' (IDs: $ids). Use the numeric Client Tenant ID instead.") + # 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). + $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.") } throw [System.InvalidOperationException]::new("No tenant found with name '$tenantIdString'. Use Get-InforcerTenant to list available tenants.") diff --git a/module/Private/Test-InforcerReportRunTerminal.ps1 b/module/Private/Test-InforcerReportRunTerminal.ps1 new file mode 100644 index 0000000..6eb2a19 --- /dev/null +++ b/module/Private/Test-InforcerReportRunTerminal.ps1 @@ -0,0 +1,128 @@ +function Test-InforcerReportRunTerminal { + <# + .SYNOPSIS + Probes GET /beta/reports/runs/{id}/outputs once to determine if a run is terminal (Private helper). + .DESCRIPTION + Per API contract: + * 200 → run is terminal; outputs are returned (downloadable) + * 404 → run is not terminal yet (or doesn't exist — see note) + + 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. + .PARAMETER RunId + The run identifier (GUID) returned from POST /beta/reports/runs. + .OUTPUTS + PSCustomObject with members: + RunId — the run identifier (string form of the guid) + IsTerminal — boolean + Outputs — the parsed outputs array (when IsTerminal=$true), else $null + CorrelationId — x-correlation-id of this probe, or $null + StatusCode — raw HTTP status code (int) + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [guid]$RunId + ) + + if (-not (Test-InforcerSession)) { + Write-Error -Message 'Not connected. Run Connect-Inforcer first.' ` + -ErrorId 'NotConnected' -Category ConnectionError + 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' + } + + 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 + } + + $statusCode = [int]$response.StatusCode + $correlationId = Get-InforcerHeaderValue -Headers $response.Headers -Name 'x-correlation-id' + if ($correlationId) { + Write-Verbose "x-correlation-id: $correlationId" + } + + if ($statusCode -eq 200) { + # Response shape (confirmed): { data: { outputs: [...] }, success: true, message, errors } + # Unwrap: response → .data → .outputs (the array of output records). + $outputs = @() + if ($response.Content) { + try { + $body = $response.Content | ConvertFrom-Json -Depth 100 -ErrorAction Stop + $unwrapped = $body + $dataProp = $body.PSObject.Properties['data'] + if ($dataProp -and $null -ne $dataProp.Value) { $unwrapped = $dataProp.Value } + if ($unwrapped -is [PSObject]) { + $outputsProp = $unwrapped.PSObject.Properties['outputs'] + if ($outputsProp -and $null -ne $outputsProp.Value) { + $outputs = @($outputsProp.Value) + } elseif ($unwrapped -is [array]) { + $outputs = @($unwrapped) + } + } elseif ($unwrapped -is [array]) { + $outputs = @($unwrapped) + } + } catch { + Write-Verbose '200 response was not JSON-parseable; returning empty outputs.' + } + } + return [PSCustomObject]@{ + RunId = $RunId.ToString() + IsTerminal = $true + Outputs = $outputs + CorrelationId = $correlationId + StatusCode = $statusCode + } + } + + if ($statusCode -eq 404) { + return [PSCustomObject]@{ + RunId = $RunId.ToString() + IsTerminal = $false + Outputs = $null + CorrelationId = $correlationId + StatusCode = $statusCode + } + } + + # Any other status is an error worth surfacing — parse the body for a useful message. + $detail = $response.Content + if ($detail) { + try { + $json = $response.Content | ConvertFrom-Json -ErrorAction Stop + $msgProp = $json.PSObject.Properties['message'] + if ($msgProp) { $detail = $msgProp.Value -as [string] } + } catch { + Write-Verbose 'Error response body was not JSON; using raw content.' + } + } + if ([string]::IsNullOrWhiteSpace($detail)) { $detail = "HTTP $statusCode" } + $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/Private/Test-InforcerSafeOutputPath.ps1 b/module/Private/Test-InforcerSafeOutputPath.ps1 new file mode 100644 index 0000000..a85d0ed --- /dev/null +++ b/module/Private/Test-InforcerSafeOutputPath.ps1 @@ -0,0 +1,77 @@ +function Test-InforcerSafeOutputPath { + <# + .SYNOPSIS + Refuses output paths that point at known OS / system directories (Private helper). + .DESCRIPTION + Defense-in-depth against server-controlled filenames + user-supplied -OutputPath + combining to write into protected locations. The Reports API's Content-Disposition + filename is sanitized to a leaf name (no traversal), but if the user passes + -OutputPath /etc and the server returns filename="passwd", we should refuse. + + Compares the canonical (resolved) path against a per-platform deny list. Returns + $true when the path is safe; throws a terminating error when it isn't. + + Out of scope: this is not a full ACL check. We don't try to determine whether the + user has write permission to the directory — only whether the directory is one we + know the module should never write to. + .PARAMETER Path + Directory the user passed via -OutputPath. May not exist yet. + .OUTPUTS + System.Boolean — always $true on success; throws otherwise. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $true } + + # Resolve to canonical absolute path. GetFullPath handles `..` and symlinks-as-text but + # does NOT follow filesystem symlinks; that's adequate for our deny check (caller can't + # use `..` to escape the leaf restriction since the path goes through GetFullPath). + $resolved = $null + try { + $resolved = [System.IO.Path]::GetFullPath($Path) + } catch { + # Malformed paths fall through — let downstream creation logic surface the error. + return $true + } + if ([string]::IsNullOrWhiteSpace($resolved)) { return $true } + + $normalized = $resolved.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + + # Platform-aware deny prefixes. On Windows we resolve %WINDIR% / %PROGRAMFILES% from env. + $denyPrefixes = @() + if ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { + foreach ($envName in 'WINDIR','SystemRoot','PROGRAMFILES','PROGRAMFILES(X86)','PROGRAMDATA') { + $v = [System.Environment]::GetEnvironmentVariable($envName) + if ($v) { $denyPrefixes += $v.TrimEnd('\','/') } + } + # Plus the fixed roots. + $denyPrefixes += @('C:\Windows','C:\Program Files','C:\Program Files (x86)','C:\ProgramData') + } else { + # macOS / Linux — block system directories. Don't block /tmp or /var/folders. + $denyPrefixes += @('/etc','/usr','/bin','/sbin','/boot','/lib','/lib32','/lib64','/System','/Library/System') + } + + foreach ($deny in $denyPrefixes) { + if ([string]::IsNullOrWhiteSpace($deny)) { continue } + $denyNorm = $deny.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + # Case-insensitive on Windows, case-sensitive on Unix — match OS behavior. + $cmp = if ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { + [System.StringComparison]::OrdinalIgnoreCase + } else { + [System.StringComparison]::Ordinal + } + if ($normalized.StartsWith($denyNorm, $cmp) -and + ($normalized.Length -eq $denyNorm.Length -or + $normalized[$denyNorm.Length] -eq [System.IO.Path]::DirectorySeparatorChar -or + $normalized[$denyNorm.Length] -eq [System.IO.Path]::AltDirectorySeparatorChar)) { + throw "Refusing to write under '$denyNorm' (system / OS directory). Choose a different -OutputPath." + } + } + + return $true +} diff --git a/module/Public/Connect-Inforcer.ps1 b/module/Public/Connect-Inforcer.ps1 index 0d8151d..7f1f3b2 100644 --- a/module/Public/Connect-Inforcer.ps1 +++ b/module/Public/Connect-Inforcer.ps1 @@ -94,42 +94,131 @@ try { if (!$PSCmdlet.ShouldProcess('Inforcer session', 'Connect')) { return } -# Validate the API key with a minimal request before reporting Connected -$validateUri = $baseUrlValue.TrimEnd('/') + '/beta/baselines' +# Validate the API key with a minimal request before reporting Connected. +# +# Validation principle: Connect-Inforcer must accept ANY valid key regardless of scope. +# We pick /beta/baselines as a probe target because it's a common endpoint, but the result +# is interpreted by RESPONSE SHAPE, not just status code: +# +# * 200 → key valid, full scope for this endpoint +# * 4xx with Inforcer-app error envelope → key valid; APIM accepted the subscription, +# the Inforcer app rejected the scope. That's +# proof the subscription works. +# * 401 with APIM gateway envelope → APIM rejected the subscription itself. Real +# auth failure — error out. +# * Other 4xx/5xx → propagate the message. +# +# APIM gateway envelope shape (rejection): { "statusCode": 401, "message": "Access denied due to invalid subscription key..." } +# Inforcer app envelope shape (scope deny): { "success": false, "errorCode": "forbidden", "message": "...", "errors": [...] } $validateHeaders = @{ 'Inf-Api-Key' = $plain 'Accept' = 'application/json' } + +$probeUri = $baseUrlValue.TrimEnd('/') + '/beta/baselines' +$probeSucceeded = $false +$probeStatusCode = 0 +$probeApiMessage = $null +$probeEnvelope = 'unknown' # 'inforcer' | 'apim' | 'unknown' +$probeRawError = $null + +# Use Invoke-WebRequest -SkipHttpErrorCheck (PS7+) so 4xx/5xx don't throw — keeps the +# parent's error stream / -ErrorVariable clean when validation goes through the +# "envelope-shape says key is valid" path even on 403. try { - $null = Invoke-RestMethod -Uri $validateUri -Method GET -Headers $validateHeaders -UseBasicParsing + # -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 { - # Parse response body from PS7 (ErrorDetails) or PS5.1 (WebException) - $statusCode = 0 - $apiMessage = $null - if ($_.Exception -is [System.Net.WebException] -and $_.Exception.Response) { - $statusCode = [int]$_.Exception.Response.StatusCode + # Only real network errors (DNS, connection refused, TLS) land here; 4xx/5xx are + # captured via the response object thanks to -SkipHttpErrorCheck. + $probeRawError = $_ + Write-Error -Message "Connection failed: $($_.Exception.Message)" ` + -ErrorId 'ConnectionValidationFailed' -Category ConnectionError + return +} + +$probeStatusCode = [int]$probeResponse.StatusCode +if ($probeStatusCode -ge 200 -and $probeStatusCode -lt 300) { + $probeSucceeded = $true +} else { + $bodyText = if ($probeResponse.Content -is [byte[]]) { + [System.Text.Encoding]::UTF8.GetString($probeResponse.Content) + } else { + $probeResponse.Content -as [string] } - if ($_.ErrorDetails -and $_.ErrorDetails.Message) { - $json = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($json) { - if ($json.PSObject.Properties['statusCode']) { $statusCode = [int]$json.statusCode } - $msgProp = $json.PSObject.Properties['message'] - if ($msgProp) { $apiMessage = $msgProp.Value -as [string] } - } + $json = $null + if ($bodyText) { + try { $json = $bodyText | ConvertFrom-Json -ErrorAction Stop } catch { $json = $null } + } + if ($json) { + $msgProp = $json.PSObject.Properties['message'] + if ($msgProp) { $probeApiMessage = $msgProp.Value -as [string] } + + # Envelope detection: Inforcer app responses carry success / errorCode / errors; + # APIM gateway responses only carry statusCode + message (no Inforcer markers). + $hasInforcerMarkers = $json.PSObject.Properties['success'] -or ` + $json.PSObject.Properties['errorCode'] -or ` + $json.PSObject.Properties['errors'] + $isApimShape = $json.PSObject.Properties['statusCode'] -and ` + $json.PSObject.Properties['message'] -and ` + (-not $hasInforcerMarkers) -and ` + (-not $json.PSObject.Properties['data']) + if ($hasInforcerMarkers) { $probeEnvelope = 'inforcer' } + elseif ($isApimShape) { $probeEnvelope = 'apim' } } +} + +# Decide validation outcome from status code AND envelope shape. +$keyValid = switch ($true) { + $probeSucceeded { $true; break } # 200 OK + ($probeStatusCode -eq 403 -and $probeEnvelope -eq 'inforcer'){ $true; break } # APIM passed, scope denied + ($probeStatusCode -eq 401 -and $probeEnvelope -eq 'inforcer'){ $true; break } # Same shape, different code on some routes + default { $false } +} + +if ($keyValid -and -not $probeSucceeded) { + Write-Verbose "Key validated against /beta/baselines via $probeEnvelope envelope (HTTP $probeStatusCode). Subscription is active; scope for /beta/baselines is not granted, but the session is established." +} + +if (-not $keyValid) { $msg = switch ($true) { - ($statusCode -eq 401) { 'Connection failed: the API key is invalid for this endpoint.' } - ($statusCode -eq 429 -or $statusCode -eq 403 -and $apiMessage -match 'quota|rate.?limit|throttl') { - if ($apiMessage) { "API rate limit: $apiMessage" } else { 'API rate limit exceeded. Please wait and try again.' } + ($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 ($apiMessage) { "Connection validation failed: $apiMessage" } else { "Connection validation failed: $($_.Exception.Message)" } + if ($probeApiMessage) { "Connection validation failed: $probeApiMessage" } + elseif ($probeRawError) { "Connection validation failed: $($probeRawError.Exception.Message)" } + else { 'Connection validation failed.' } } } Write-Error -Message $msg -ErrorId 'ConnectionValidationFailed' -Category AuthenticationError return } +# Clear stale caches from any prior session before establishing the new one. This catches +# the denial sentinels ($script:InforcerAssessmentCacheDeniedAt, ...) that would otherwise +# persist after a re-key — a user who reconnects with a higher-scope key shouldn't still +# see "scope required" hints from the previous one. Uses the same wildcard pattern that +# Disconnect-Inforcer uses, so any future Inforcer*Cache* var is included automatically. +$cacheVarsToClear = Get-Variable -Scope Script -Name 'Inforcer*Cache*' -ErrorAction SilentlyContinue +if ($cacheVarsToClear) { + $cacheVarsToClear | Clear-Variable -Scope Script -Force -ErrorAction SilentlyContinue + Write-Verbose ('Cleared {0} stale cache variable(s) before establishing new session.' -f $cacheVarsToClear.Count) +} + $script:InforcerSession = @{ ApiKey = $secureApiKey BaseUrl = $baseUrlValue @@ -139,6 +228,42 @@ $script:InforcerSession = @{ Write-Verbose "Successfully connected to Inforcer API at $baseUrlValue" +# Best-effort prime of the Reports catalog cache so Get-InforcerReportType / +# Invoke-InforcerReport argument completers show the live key list on the very +# first TAB. Silent on any failure (key may lack Reports.Read, transient network, +# rate limit) — the cmdlets re-prime on first invocation via Resolve-InforcerReportTypeSchema. +# Skip prime when cache is already populated (cache-clear above runs first, so reaching +# here with populated cache means a defensive condition we shouldn't overwrite). +$skipPrime = $false +if ($script:InforcerReportTypeCache -and @($script:InforcerReportTypeCache).Count -gt 0) { + Write-Verbose 'Reports catalog cache already populated — skipping prime.' + $skipPrime = $true +} +if (-not $skipPrime) { try { + $catalogProbe = Invoke-WebRequest -Uri ($baseUrlValue.TrimEnd('/') + '/beta/reports/types') ` + -Method GET -Headers $validateHeaders -UseBasicParsing -SkipHttpErrorCheck ` + -TimeoutSec 4 -ErrorAction Stop + if ([int]$catalogProbe.StatusCode -ge 200 -and [int]$catalogProbe.StatusCode -lt 300) { + $catalogJson = if ($catalogProbe.Content -is [byte[]]) { + [System.Text.Encoding]::UTF8.GetString($catalogProbe.Content) + } else { $catalogProbe.Content -as [string] } + if ($catalogJson) { + $parsed = $catalogJson | ConvertFrom-Json -ErrorAction Stop + $dataProp = $parsed.PSObject.Properties['data'] + $entries = if ($dataProp) { @($dataProp.Value) } else { @($parsed) } + if ($entries.Count -gt 0) { + $script:InforcerReportTypeCache = $entries + $script:InforcerReportTypeCacheStamp = Get-Date + Write-Verbose "Primed Reports catalog cache ($($entries.Count) types) for argument completion." + } + } + } else { + Write-Verbose "Skipping Reports catalog prime: HTTP $([int]$catalogProbe.StatusCode) (key likely lacks Reports.Read)." + } +} catch { + Write-Verbose "Skipping Reports catalog prime: $($_.Exception.Message)" +} } # end of if (-not $skipPrime) wrapper + if ($PassThru) { # Return a clone of the session hashtable so callers have an independent copy $sessionCopy = @{ diff --git a/module/Public/Disconnect-Inforcer.ps1 b/module/Public/Disconnect-Inforcer.ps1 index 675de73..07bbe7c 100644 --- a/module/Public/Disconnect-Inforcer.ps1 +++ b/module/Public/Disconnect-Inforcer.ps1 @@ -28,6 +28,15 @@ if ($script:InforcerSession -and $script:InforcerSession.ApiKey -and $script:Inf $script:InforcerSession = $null $script:InforcerSettingsCatalog = $null + # Clear all module-scoped caches (any $script:Inforcer*Cache* variable). This catches + # the simple cache names (InforcerAssessmentCache, InforcerReportTypeCache, ...) and + # the sentinel/timestamp companions (InforcerAssessmentCacheDeniedAt, etc.). + $cacheVars = Get-Variable -Scope Script -Name 'Inforcer*Cache*' -ErrorAction SilentlyContinue + if ($cacheVars) { + $cacheVars | Clear-Variable -Scope Script -Force -ErrorAction SilentlyContinue + Write-Verbose ('Cleared {0} cache variable(s): {1}' -f $cacheVars.Count, (($cacheVars.Name) -join ', ')) + } + # Also disconnect Microsoft Graph if it was connected via this module if ($script:InforcerGraphConnected) { try { diff --git a/module/Public/Get-InforcerAssessment.ps1 b/module/Public/Get-InforcerAssessment.ps1 index 27b9ed5..3b6f0e7 100644 --- a/module/Public/Get-InforcerAssessment.ps1 +++ b/module/Public/Get-InforcerAssessment.ps1 @@ -64,5 +64,10 @@ if ($result -is [array]) { $result.PSObject.TypeNames.Insert(0, 'InforcerCommunity.Assessment') } +# Prime the assessment cache so the -AssessmentId tab completer on Invoke-InforcerReport +# is instant. Successful list call also clears any prior denial sentinel. +$script:InforcerAssessmentCache = @($result) +$script:InforcerAssessmentCacheDeniedAt = $null + $result } diff --git a/module/Public/Get-InforcerReportRun.ps1 b/module/Public/Get-InforcerReportRun.ps1 new file mode 100644 index 0000000..ddc2123 --- /dev/null +++ b/module/Public/Get-InforcerReportRun.ps1 @@ -0,0 +1,251 @@ +<# +.SYNOPSIS + Lists report runs from the Inforcer Reports API, with optional polling and outputs embedding. + + Required API scope(s): Reports.Read +.DESCRIPTION + GET /beta/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: + + * Propagation lag: a newly-queued run does not appear in the list response for ~4 minutes. + For freshly-queued work, use Invoke-InforcerReport (which polls the outputs endpoint + directly) rather than -RunId here. + * No filter support: server ignores ?status=, ?since=, ?limit=, etc. Filtering happens + in this cmdlet. + + Polling and outputs embedding are opt-in: -Wait polls the outputs endpoint (which 404s + until terminal, then 200s — bypasses the list-endpoint lag); -IncludeOutputs fetches + /runs/{id}/outputs once per returned run and attaches the outputs array. +.PARAMETER RunId + 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. +.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. +.PARAMETER TimeoutSeconds + Maximum total wait when -Wait is used. Default: 600. +.PARAMETER PollIntervalSeconds + Initial poll interval (doubles up to a 15-second cap). Default: 2. +.PARAMETER Format + Raw (default). Reserved. +.PARAMETER OutputType + PowerShellObject (default) or JsonObject. JSON uses Depth 100. +.EXAMPLE + Get-InforcerReportRun + Lists every visible run (up to 500, last 7 days). +.EXAMPLE + Get-InforcerReportRun -RunId 094a49ed-b9b8-492b-870f-0f76fd3b2954 + Returns metadata for a single run (subject to the list-endpoint propagation lag). +.EXAMPLE + Get-InforcerReportRun -RunId 094a49ed-b9b8-492b-870f-0f76fd3b2954 -Wait + Polls the run until terminal and returns its metadata with outputs embedded. +.EXAMPLE + Get-InforcerReportRun -IncludeOutputs + Returns every run with its outputs array embedded (1 extra API call per run). +.OUTPUTS + PSObject or String +.LINK + https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#get-inforcerreportrun +.LINK + Invoke-InforcerReport +.LINK + Save-InforcerReportOutput +#> +function Get-InforcerReportRun { +[CmdletBinding()] +[OutputType([PSObject], [string])] +param( + [Parameter(Mandatory = $false, Position = 0, ValueFromPipelineByPropertyName = $true)] + [Alias('Id')] + [guid]$RunId, + + [Parameter(Mandatory = $false)] + [switch]$Wait, + + [Parameter(Mandatory = $false)] + [switch]$IncludeOutputs, + + [Parameter(Mandatory = $false)] + [ValidateRange(10, 3600)] + [int]$TimeoutSeconds = 600, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 60)] + [int]$PollIntervalSeconds = 2, + + [Parameter(Mandatory = $false)] + [ValidateSet('Raw')] + [string]$Format = 'Raw', + + [Parameter(Mandatory = $false)] + [ValidateSet('PowerShellObject', 'JsonObject')] + [string]$OutputType = 'PowerShellObject' +) + +begin { + $sessionOk = Test-InforcerSession + if (-not $sessionOk) { + Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' ` + -ErrorId 'NotConnected' -Category ConnectionError + } + # Cache the list-endpoint response across process{} iterations so piping N RunIds is + # one GET, not N GETs. Per-invocation local — not $script:* — so concurrent invocations + # don't share state. + $cachedListResponse = $null + $listResponseFetched = $false + $polledOutputsByRunId = @{} +} + +process { + if (-not $sessionOk) { return } + +if ($Wait.IsPresent -and -not $PSBoundParameters.ContainsKey('RunId')) { + Write-Error -Message '-Wait requires -RunId. Use Invoke-InforcerReport for batch polling.' ` + -ErrorId 'WaitRequiresRunId' -Category InvalidArgument + return +} + +# --- Optional polling stage (uses outputs endpoint to bypass list-endpoint lag) --- +$polledOutputs = $null +if ($Wait.IsPresent) { + Write-Verbose "Polling run $RunId until terminal (timeout: ${TimeoutSeconds}s)..." + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $interval = [Math]::Max($PollIntervalSeconds, 1) + while ($true) { + try { + $probe = Test-InforcerReportRunTerminal -RunId $RunId -ErrorAction Stop + } catch { + Write-Error -Message $_.Exception.Message -ErrorId 'PollFailed' -Category ConnectionError + return + } + if ($probe.IsTerminal) { + $polledOutputs = $probe.Outputs + $polledOutputsByRunId[$RunId.ToString()] = $polledOutputs + break + } + if ((Get-Date) -ge $deadline) { + Write-Error -Message "Timed out waiting for run $RunId to complete (waited ${TimeoutSeconds}s)." ` + -ErrorId 'PollTimeout' -Category OperationStopped + return + } + Start-Sleep -Seconds $interval + $interval = [Math]::Min($interval * 2, 15) + } +} + +# --- 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 + $listResponseFetched = $true +} +$response = $cachedListResponse +if ($null -eq $response) { return } + +$runs = @($response) + +if ($PSBoundParameters.ContainsKey('RunId')) { + $needle = $RunId.ToString() + # API uses 'runId' on list records; older code paths used 'id' — match either. Single pass. + $matched = [System.Collections.Generic.List[object]]::new() + foreach ($r in $runs) { + $runIdProp = $r.PSObject.Properties['runId'] + if ($runIdProp -and (($runIdProp.Value -as [string]) -ieq $needle)) { [void]$matched.Add($r); continue } + $idProp = $r.PSObject.Properties['id'] + if ($idProp -and (($idProp.Value -as [string]) -ieq $needle)) { [void]$matched.Add($r) } + } + $runs = $matched.ToArray() + if ($runs.Count -eq 0) { + if ($polledOutputs) { + # Polling confirmed the run is terminal, but the list endpoint hasn't caught up. + Write-Warning "Run $RunId is terminal but not yet visible in GET /reports/runs (4-minute propagation lag). Returning a synthesized record with outputs embedded." + $runs = @([PSCustomObject]@{ + runId = $needle + status = 'completed' + outputs = @($polledOutputs) + }) + } elseif ($IncludeOutputs.IsPresent) { + # -IncludeOutputs without -Wait: try a one-shot outputs probe to bypass the list-endpoint lag. + try { + $probe = Test-InforcerReportRunTerminal -RunId $RunId -ErrorAction Stop + } catch { + Write-Warning "Run $RunId not found in GET /reports/runs and outputs probe failed: $($_.Exception.Message)" + return + } + if ($probe.IsTerminal) { + Write-Warning "Run $RunId is terminal but not yet visible in GET /reports/runs (4-minute propagation lag). Returning a synthesized record with outputs embedded." + $runs = @([PSCustomObject]@{ + runId = $needle + status = 'completed' + outputs = @($probe.Outputs) + }) + } else { + Write-Warning "Run $RunId is not yet terminal and not in GET /reports/runs. Try -Wait, or retry once the run completes." + return + } + } else { + Write-Warning "Run $RunId not found in GET /reports/runs. The list endpoint has a ~4-minute propagation lag; for fresh runs, pass -IncludeOutputs (or -Wait) to bypass via the outputs endpoint." + return + } + } elseif ($polledOutputs) { + # Attach polled outputs to the (single) matching run. + foreach ($r in $runs) { + if (-not $r.PSObject.Properties['outputs']) { + $r | Add-Member -NotePropertyName 'outputs' -NotePropertyValue @($polledOutputs) -Force + } + } + } +} + +# --- Optionally embed outputs for every returned run --- +if ($IncludeOutputs.IsPresent -and -not $polledOutputs) { + foreach ($r in $runs) { + $idProp = $r.PSObject.Properties['runId'] + if (-not $idProp) { $idProp = $r.PSObject.Properties['id'] } + $idValue = if ($idProp) { $idProp.Value -as [string] } else { $null } + $idGuid = [guid]::Empty + if (-not [guid]::TryParse($idValue, [ref]$idGuid)) { + Write-Warning "Run record missing valid runId; cannot embed outputs." + continue + } + # Skip if outputs are already attached (e.g. from synthesized lag-fallback record). + $existing = $r.PSObject.Properties['outputs'] + if ($existing -and $existing.Value) { continue } + try { + $probe = Test-InforcerReportRunTerminal -RunId $idGuid -ErrorAction Stop + if ($probe.IsTerminal) { + if (-not $r.PSObject.Properties['outputs']) { + $r | Add-Member -NotePropertyName 'outputs' -NotePropertyValue @($probe.Outputs) -Force + } else { + $r.PSObject.Properties['outputs'].Value = @($probe.Outputs) + } + } else { + Write-Verbose "Run $idValue is not yet terminal; skipping outputs embedding." + } + } catch { + Write-Warning "Failed to fetch outputs for run ${idValue}: $($_.Exception.Message)" + } + } +} + +# --- Shape output --- +if ($OutputType -eq 'JsonObject') { + return ($runs | ConvertTo-Json -Depth 100) +} + +foreach ($r in $runs) { + if ($r -is [PSObject]) { + $null = Add-InforcerPropertyAliases -InputObject $r -ObjectType ReportRun + if ($r.PSObject.TypeNames[0] -ne 'InforcerCommunity.ReportRun') { + $r.PSObject.TypeNames.Insert(0, 'InforcerCommunity.ReportRun') + } + } +} +$runs + +} # end of process{} block +} diff --git a/module/Public/Get-InforcerReportType.ps1 b/module/Public/Get-InforcerReportType.ps1 new file mode 100644 index 0000000..d9cef72 --- /dev/null +++ b/module/Public/Get-InforcerReportType.ps1 @@ -0,0 +1,238 @@ +<# +.SYNOPSIS + Lists available report types from the Inforcer Reports API. + + Required API scope(s): Reports.Read +.DESCRIPTION + Retrieves the catalog of report types from GET /beta/reports/types. 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. + + Output is shaped for cmdlet consumption: PascalCase property aliases are added and + the InforcerCommunity.ReportType PSTypeName is inserted to drive default formatting. +.PARAMETER Key + Filter to a single report type by key (case-insensitive). Aliases: -Name, -ReportType. +.PARAMETER Tag + Filter to types containing the specified tag (case-insensitive). +.PARAMETER OutputFormat + Filter to types that support the specified output format (e.g. csv, json, pdf, html). +.PARAMETER Force + Bypass the in-memory cache and refetch the catalog from the API. +.PARAMETER Format + Raw (default). Reserved for future shape changes. +.PARAMETER OutputType + PowerShellObject (default) or JsonObject. JSON uses Depth 100. +.EXAMPLE + Get-InforcerReportType + Lists every available report type. +.EXAMPLE + Get-InforcerReportType -Key CopilotAdoption + Returns the single CopilotAdoption catalog entry. +.EXAMPLE + Get-InforcerReportType -OutputFormat pdf + Lists every type that supports PDF output. +.EXAMPLE + Get-InforcerReportType -Tag security + Lists every type tagged with 'security'. +.EXAMPLE + Get-InforcerReportType -Tag security | Invoke-InforcerReport -OutputFormat csv -TenantId 482 + Discovers every security-tagged report type and pipes each into Invoke-InforcerReport. + Works because Get-InforcerReportType emits a 'Key' alias which binds to + Invoke-InforcerReport's -ReportType (ValueFromPipelineByPropertyName). +.OUTPUTS + PSObject or String +.LINK + https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#get-inforcerreporttype +.LINK + Connect-Inforcer +.LINK + Invoke-InforcerReport +#> +function Get-InforcerReportType { +[CmdletBinding()] +[OutputType([PSObject], [string])] +param( + [Parameter(Mandatory = $false, Position = 0)] + [Alias('Name', 'ReportType')] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + # ArgumentCompleter scriptblocks run in the *caller's* session state, NOT the module's, + # so $script:InforcerReportTypeCache / $script:InforcerReportTypeStaticKeys are invisible + # from here. Bounce through the module's session via Get-Module so $script:* resolves. + # The static-fallback list and live cache both live in Private/Get-InforcerReportTypeStaticKeys.ps1. + $module = Get-Module InforcerCommunity + $candidates = if ($module) { + & $module { + if ($script:InforcerReportTypeCache) { + $tmp = [System.Collections.Generic.List[string]]::new() + foreach ($e in $script:InforcerReportTypeCache) { + if ($e -isnot [PSObject]) { continue } + $keyProp = $e.PSObject.Properties['key'] + if (-not $keyProp) { continue } + $v = $keyProp.Value -as [string] + if ($v) { [void]$tmp.Add($v) } + } + # If cache existed but yielded zero keys (malformed entries / unexpected shape), + # fall back to the static list rather than letting PS use filesystem completion. + if ($tmp.Count -eq 0) { ,$script:InforcerReportTypeStaticKeys } else { ,$tmp.ToArray() } + } else { + ,$script:InforcerReportTypeStaticKeys + } + } + } else { @() } + $matched = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($c in $candidates) { if ($c -like "$wordToComplete*") { [void]$matched.Add($c) } } + foreach ($c in $matched) { + [System.Management.Automation.CompletionResult]::new($c, $c, 'ParameterValue', $c) + } + })] + [string]$Key, + + [Parameter(Mandatory = $false)] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + # ArgumentCompleter scriptblocks run in the caller's session state, so $script:* + # variables defined in the module are invisible from here — bounce through the module + # to read the live tag set from the cached catalog. + $known = @('Adoption','Identity','Productivity','Security') + $module = Get-Module InforcerCommunity + $candidates = if ($module) { + & $module { + if ($script:InforcerReportTypeCache) { + $tmp = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($e in $script:InforcerReportTypeCache) { + if ($null -eq $e -or $e -isnot [PSObject]) { continue } + $tagsProp = $e.PSObject.Properties['tags'] + if ($tagsProp) { + foreach ($t in @($tagsProp.Value)) { + $tStr = $t -as [string] + if ($tStr) { [void]$tmp.Add($tStr) } + } + } + } + ,$tmp.ToArray() + } else { + ,@() + } + } + } else { @() } + if (-not $candidates -or @($candidates).Count -eq 0) { $candidates = $known } + foreach ($c in $candidates) { + if ($c -like "$wordToComplete*") { + [System.Management.Automation.CompletionResult]::new($c, $c, 'ParameterValue', $c) + } + } + })] + [string]$Tag, + + [Parameter(Mandatory = $false)] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $formats = @('csv','json','html','pdf','xlsx') + foreach ($f in $formats) { + if ($f -like "$wordToComplete*") { + [System.Management.Automation.CompletionResult]::new($f, $f, 'ParameterValue', $f) + } + } + })] + [string]$OutputFormat, + + [Parameter(Mandatory = $false)] + [switch]$Force, + + [Parameter(Mandatory = $false)] + [ValidateSet('Raw')] + [string]$Format = 'Raw', + + [Parameter(Mandatory = $false)] + [ValidateSet('PowerShellObject', 'JsonObject')] + [string]$OutputType = 'PowerShellObject' +) + +if (-not (Test-InforcerSession)) { + Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' ` + -ErrorId 'NotConnected' -Category ConnectionError + return +} + +# Fetch (or use cache) — JsonObject path always refetches so the response shape isn't +# 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 + if ($null -eq $response) { return } + + if ($OutputType -eq 'JsonObject') { + # Caller asked for raw JSON — no filtering / aliasing supported in this mode. + return $response + } + + $script:InforcerReportTypeCache = @($response) + $script:InforcerReportTypeCacheStamp = Get-Date +} + +$catalog = @($script:InforcerReportTypeCache) + +# Filtering — single pass over the cache applying all 3 predicates short-circuit. +$applyKey = $PSBoundParameters.ContainsKey('Key') -and -not [string]::IsNullOrWhiteSpace($Key) +$applyTag = $PSBoundParameters.ContainsKey('Tag') -and -not [string]::IsNullOrWhiteSpace($Tag) +$applyFormat = $PSBoundParameters.ContainsKey('OutputFormat') -and -not [string]::IsNullOrWhiteSpace($OutputFormat) +if ($applyKey -or $applyTag -or $applyFormat) { + $filtered = [System.Collections.Generic.List[object]]::new() + :outer foreach ($entry in $catalog) { + if ($applyKey) { + $keyProp = $entry.PSObject.Properties['key'] + if (-not $keyProp -or (($keyProp.Value -as [string]) -ine $Key)) { continue outer } + } + if ($applyTag) { + $tagsProp = $entry.PSObject.Properties['tags'] + $hit = $false + if ($tagsProp) { + # Handle both shapes the API may return: an array of tag strings, OR a single + # comma-separated string (live observed). Split on commas defensively when we + # see a string, trim whitespace, and match case-insensitively against each entry. + foreach ($raw in @($tagsProp.Value)) { + $rawStr = $raw -as [string] + if (-not $rawStr) { continue } + foreach ($part in ($rawStr -split ',')) { + if ($part.Trim() -ieq $Tag) { $hit = $true; break } + } + if ($hit) { break } + } + } + if (-not $hit) { continue outer } + } + if ($applyFormat) { + $formats = $null + foreach ($candidate in 'supportedOutputFormats','outputFormats','supportedFormats','formats') { + if ($entry.PSObject.Properties[$candidate]) { + $formats = $entry.PSObject.Properties[$candidate].Value + break + } + } + $hit = $false + if ($formats) { + foreach ($f in @($formats)) { + if (($f -as [string]) -ieq $OutputFormat) { $hit = $true; break } + } + } + if (-not $hit) { continue outer } + } + [void]$filtered.Add($entry) + } + $catalog = $filtered.ToArray() +} + +# Apply aliases + PSTypeName for clean default display. +foreach ($item in $catalog) { + if ($item -is [PSObject]) { + $null = Add-InforcerPropertyAliases -InputObject $item -ObjectType ReportType + if ($item.PSObject.TypeNames[0] -ne 'InforcerCommunity.ReportType') { + $item.PSObject.TypeNames.Insert(0, 'InforcerCommunity.ReportType') + } + } +} + +$catalog +} diff --git a/module/Public/Invoke-InforcerReport.ps1 b/module/Public/Invoke-InforcerReport.ps1 new file mode 100644 index 0000000..8f89eec --- /dev/null +++ b/module/Public/Invoke-InforcerReport.ps1 @@ -0,0 +1,866 @@ +<# +.SYNOPSIS + Queues one or more Inforcer reports, waits for completion, and saves the outputs. + + Required API scope(s): Reports.Read, Reports.Run +.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 + until each run is terminal, downloads every output to the current directory (or -OutputPath), + and emits a result object per saved file. + + Use -NoWait to return immediately after queueing (no polling, no download); use -NoSave + to poll and return outputs metadata without writing files. + + Zip/broadcast rules for -ReportType / -OutputFormat: + N reports + 1 format → broadcast (format applied to every report) + N reports + N formats → paired by index + N reports + M formats → error (M ≠ 1 and M ≠ N) + 1 report + N formats → error (would trigger duplicate-report-render bug) + + Client-side guards (verified empirically against the Inforcer Reports API): + * Duplicate (type, outputFormat) pairs are removed before POST — known server-side bug + 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. +.PARAMETER ReportType + One or more report type keys (e.g. CopilotAdoption, TenantAuditReport, Assessment). Case- + insensitive. Get-InforcerReportType lists every available key. Pipeline-bindable by + property name. +.PARAMETER OutputFormat + 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. +.PARAMETER ReportPeriod + Optional integer days — applied as the report-period parameter when supported. + CopilotAdoption and ShadowAiDetection auto-default to 30 when omitted. +.PARAMETER AssessmentId + Required when ReportType is 'Assessment'. The assessment identifier — an opaque + alphanumeric string (NOT a GUID, e.g. 'l1f8wd29pl44pp1j66r9'). Tab completion + accepts the friendly name and inserts the ID. Get-InforcerAssessment lists available + IDs. +.PARAMETER Parameter + Escape hatch for future API parameters not covered by typed switches. Hashtable; keys + must match the catalog's accepted parameter keys. +.PARAMETER Collate + Request a single cross-tenant output for collatable report types. Server silently + ignores this flag on non-collatable types — this cmdlet errors instead. +.PARAMETER NoWait + Return immediately after POST with run identifiers. Skip polling and download. +.PARAMETER NoSave + Poll until terminal but do not download the output bytes. Returns outputs metadata. +.PARAMETER Open + After each output is saved, launch it with the OS default handler (Invoke-Item). + Ignored when -NoWait or -NoSave is set (nothing was saved to open). Aliases: -Show, -ShowResult. +.PARAMETER OutputPath + Directory where downloaded outputs are written. Defaults to the current working directory. + Created if it doesn't exist. +.PARAMETER TimeoutSeconds + Maximum total time to wait for any run to become terminal. Default: 600 (10 minutes). +.PARAMETER PollIntervalSeconds + Initial poll interval. Doubles up to a 15-second cap. Default: 2. +.PARAMETER Format + Raw (default). Reserved. +.PARAMETER OutputType + PowerShellObject (default) or JsonObject. Affects pipeline output only; the saved files + are unchanged. +.EXAMPLE + Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 + Queues, waits, and saves ActiveUserCount.csv to the current directory. +.EXAMPLE + Invoke-InforcerReport -ReportType TenantAuditReport, ActiveUserCount -OutputFormat pdf, csv -TenantId 482 + Pairs by index: TenantAuditReport→pdf, ActiveUserCount→csv. +.EXAMPLE + Invoke-InforcerReport -ReportType CopilotAdoption -OutputFormat csv -TenantId 482, 139 -NoWait + Submits and returns immediately with the RunIds. +.EXAMPLE + Invoke-InforcerReport -ReportType Assessment -AssessmentId l1f8wd29pl44pp1j66r9 -OutputFormat pdf -TenantId 482 + Runs a specific assessment as a report. +.EXAMPLE + Get-InforcerReportType -Tag Security | Invoke-InforcerReport -OutputFormat csv -TenantId 482 + Pipeline-discover-then-run: each catalog entry's 'Key' alias binds to -ReportType + via ValueFromPipelineByPropertyName, so every Security-tagged type is queued in + one call. +.EXAMPLE + Invoke-InforcerReport -Key TenantAuditReport -OutputFormat html -TenantId 482 -Open + Runs the report, saves it, and immediately opens the HTML in the default browser. +.OUTPUTS + PSObject or String. The shape depends on the mode: + -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, ... } +.LINK + https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#invoke-inforcerreport +.LINK + Get-InforcerReportType +.LINK + Get-InforcerReportRun +.LINK + Save-InforcerReportOutput +#> +function Invoke-InforcerReport { +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] +[OutputType([PSObject], [string])] +param( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 0)] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + # ArgumentCompleter scriptblocks run in the *caller's* session state, NOT the module's, + # so $script:* is invisible from here — bounce through the module to read both the live + # catalog cache and the canonical static-key fallback. + $module = Get-Module InforcerCommunity + $candidates = if ($module) { + & $module { + if ($script:InforcerReportTypeCache) { + $tmp = [System.Collections.Generic.List[string]]::new() + foreach ($entry in $script:InforcerReportTypeCache) { + if ($entry -isnot [PSObject]) { continue } + $keyProp = $entry.PSObject.Properties['key'] + if (-not $keyProp) { continue } + $v = $keyProp.Value -as [string] + if ($v) { [void]$tmp.Add($v) } + } + if ($tmp.Count -eq 0) { ,$script:InforcerReportTypeStaticKeys } else { ,$tmp.ToArray() } + } else { + ,$script:InforcerReportTypeStaticKeys + } + } + } else { @() } + $matched = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($c in $candidates) { if ($c -like "$wordToComplete*") { [void]$matched.Add($c) } } + foreach ($c in $matched) { + [System.Management.Automation.CompletionResult]::new($c, $c, 'ParameterValue', $c) + } + })] + [Alias('Key')] + [string[]]$ReportType, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + # Bounce through the module so $script:InforcerReportTypeCache is visible (completers + # otherwise run in the caller's session state where module $script: vars are invisible). + $boundReport = @($fakeBoundParameters['ReportType']) + $module = Get-Module InforcerCommunity + $formats = [System.Collections.Generic.List[string]]::new() + if ($module -and $boundReport.Count -gt 0) { + $collected = & $module { + param($bound) + $out = [System.Collections.Generic.List[string]]::new() + if (-not $script:InforcerReportTypeCache) { return ,$out.ToArray() } + foreach ($rt in $bound) { + $rtStr = $rt -as [string] + $entry = $null + foreach ($e in $script:InforcerReportTypeCache) { + if ($null -eq $e -or $e -isnot [PSObject]) { continue } + $keyProp = $e.PSObject.Properties['key'] + if ($keyProp -and (($keyProp.Value -as [string]) -ieq $rtStr)) { $entry = $e; break } + } + if ($entry) { + foreach ($p in 'supportedOutputFormats','outputFormats','supportedFormats','formats') { + if ($entry.PSObject.Properties[$p]) { + foreach ($f in @($entry.PSObject.Properties[$p].Value)) { + $fStr = $f -as [string] + if ($fStr) { [void]$out.Add($fStr) } + } + break + } + } + } + } + ,$out.ToArray() + } $boundReport + foreach ($f in $collected) { [void]$formats.Add($f) } + } + if ($formats.Count -eq 0) { $formats = [System.Collections.Generic.List[string]]@('csv','json','html','pdf','xlsx') } + $matched = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($f in $formats) { if ($f -like "$wordToComplete*") { [void]$matched.Add($f) } } + foreach ($f in $matched) { + [System.Management.Automation.CompletionResult]::new($f, $f, 'ParameterValue', $f) + } + })] + [string[]]$OutputFormat, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [Alias('ClientTenantId')] + [object[]]$TenantId, + + [Parameter(Mandatory = $false)] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $boundReport = @($fakeBoundParameters['ReportType']) + $values = if ($boundReport -contains 'CopilotAdoption') { 7, 30, 90 } + elseif ($boundReport -contains 'ShadowAiDetection') { 30, 90 } + else { @() } + $values | Where-Object { "$_" -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new("$_", "$_", 'ParameterValue', "$_") + } + })] + [ValidateRange(1, 365)] + [int]$ReportPeriod, + + [Parameter(Mandatory = $false)] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + + # If the user has explicitly typed -ReportType , + # the completer is irrelevant — return empty. + $bound = @($fakeBoundParameters['ReportType']) + if ($bound.Count -gt 0) { + $hasAssessment = $false + foreach ($t in $bound) { + if (($t -as [string]) -ieq 'Assessment') { $hasAssessment = $true; break } + } + if (-not $hasAssessment) { return @() } + } + + $module = Get-Module InforcerCommunity + if (-not $module) { + return @( + [System.Management.Automation.CompletionResult]::new( + "''", '', 'ParameterValue', + 'Run Import-Module InforcerCommunity first.' + ) + ) + } + + # Snapshot session + cache + denial sentinel inside module scope. + $state = & $module { + [PSCustomObject]@{ + Connected = [bool]($script:InforcerSession -and $script:InforcerSession.ApiKey -and $script:InforcerSession.BaseUrl) + Cache = $script:InforcerAssessmentCache + Denied = $script:InforcerAssessmentCacheDeniedAt + BaseUrl = if ($script:InforcerSession) { $script:InforcerSession.BaseUrl } else { $null } + ApiKey = if ($script:InforcerSession) { $script:InforcerSession.ApiKey } else { $null } + } + } + + if (-not $state.Connected) { + return @( + [System.Management.Automation.CompletionResult]::new( + "''", '', 'ParameterValue', + 'No active Inforcer session. Run Connect-Inforcer to enable assessment ID completion.' + ) + ) + } + + 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.' + ) + ) + } + + # Build completion results from a cached list, falling back to a lazy fetch. + $items = $state.Cache + 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) + & $module ([scriptblock]::Create('$script:InforcerAssessmentCache = $args[0]')) $items + } catch { + # 401 (APIM gateway) or 403 → cache denial so subsequent TABs are instant. + $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) { + & $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).' + ) + ) + } + # Transient: empty completion, no caching, retry on next TAB. + return @() + } + } + + # Filter + shape results: GUID as CompletionText, friendly name as ListItemText. + $needle = $wordToComplete + if (-not $needle) { $needle = '' } + $filtered = foreach ($a in @($items)) { + # Skip null entries and non-PSObject items defensively — PowerShell silently swallows + # any exception from a completer scriptblock, which would manifest as "TAB does nothing". + if ($null -eq $a -or $a -isnot [PSObject]) { continue } + $idVal = $a.PSObject.Properties['Id'] + if (-not $idVal) { $idVal = $a.PSObject.Properties['id'] } + $nameVal = $a.PSObject.Properties['Name'] + if (-not $nameVal) { $nameVal = $a.PSObject.Properties['name'] } + $id = if ($idVal) { $idVal.Value -as [string] } else { $null } + $name = if ($nameVal) { $nameVal.Value -as [string] } else { $null } + if (-not $id) { continue } + # Match either ID prefix or substring on name (case-insensitive). + if ($id -like "${needle}*" -or ($name -and $name -like "*${needle}*")) { + $listItem = if ($name) { $name } else { $id } + $tooltip = if ($name) { "$name — $id" } else { $id } + [System.Management.Automation.CompletionResult]::new($id, $listItem, 'ParameterValue', $tooltip) + } + } + @($filtered) + })] + [string]$AssessmentId, + + [Parameter(Mandatory = $false)] + [hashtable]$Parameter, + + [Parameter(Mandatory = $false)] + [switch]$Collate, + + [Parameter(Mandatory = $false)] + [switch]$NoWait, + + [Parameter(Mandatory = $false)] + [switch]$NoSave, + + [Parameter(Mandatory = $false)] + [Alias('Show', 'ShowResult')] + [switch]$Open, + + [Parameter(Mandatory = $false)] + [string]$OutputPath = $PWD.Path, + + [Parameter(Mandatory = $false)] + [ValidateRange(10, 3600)] + [int]$TimeoutSeconds = 600, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 60)] + [int]$PollIntervalSeconds = 2, + + [Parameter(Mandatory = $false)] + [ValidateSet('Raw')] + [string]$Format = 'Raw', + + [Parameter(Mandatory = $false)] + [ValidateSet('PowerShellObject', 'JsonObject')] + [string]$OutputType = 'PowerShellObject' +) + +begin { + # Accumulators for pipeline-bound inputs. When the cmdlet is called with arrays directly + # (no pipe), process{} fires once and we use the bound values as-is. When called via + # Get-InforcerReportType | Invoke-InforcerReport, each piped object brings one ReportType + # (alias 'Key' from the catalog entry) — we collect them here and process the whole batch + # in end{}. + # + # Locals (NOT $script:*) — these persist across begin/process/end of the same invocation + # and stay isolated between concurrent calls in the same runspace. + $pipedTypes = [System.Collections.Generic.List[string]]::new() + $pipedFormats = [System.Collections.Generic.List[string]]::new() + $pipedTenants = [System.Collections.Generic.List[object]]::new() + $fromPipeline = $false +} + +process { + # Only accumulate when actually piped (avoids duplicate collection when invoked directly). + if ($PSCmdlet.MyInvocation.ExpectingInput) { + $fromPipeline = $true + foreach ($v in $ReportType) { if (-not [string]::IsNullOrWhiteSpace(($v -as [string]))) { [void]$pipedTypes.Add(($v -as [string])) } } + foreach ($v in $OutputFormat) { if (-not [string]::IsNullOrWhiteSpace(($v -as [string]))) { [void]$pipedFormats.Add(($v -as [string])) } } + foreach ($v in $TenantId) { [void]$pipedTenants.Add($v) } + } +} + +end { + +# Replay accumulated piped values into the same parameter vars the body uses. +if ($fromPipeline) { + $ReportType = $pipedTypes.ToArray() + $OutputFormat = $pipedFormats.ToArray() + if ($pipedTenants.Count -gt 0) { + $TenantId = $pipedTenants.ToArray() + } + # If every piped item carried the same OutputFormat / TenantId (the common case), collapse + # to a single value so the zip/broadcast logic below treats it as a 1→N broadcast instead + # of an N→N pairing. + $uniqueFormats = $OutputFormat | Sort-Object -Unique + if ($uniqueFormats.Count -eq 1) { $OutputFormat = @($uniqueFormats) } + $uniqueTenants = $TenantId | ForEach-Object { $_ -as [string] } | Sort-Object -Unique + if ($uniqueTenants.Count -eq 1) { + $TenantId = @($TenantId[0]) + } elseif ($uniqueTenants.Count -gt 1) { + # User piped different tenants per row — semantics here are cross-product, not row-wise + # pairing (PowerShell pipeline binding for array params can't express row-wise across + # multiple parameters cleanly). Warn so they know they're getting every type × every tenant. + Write-Warning ("Piped {0} different tenant IDs alongside {1} report types — every report will be queued against every tenant (cross-product). To target specific (type, tenant) pairs, call Invoke-InforcerReport separately for each pairing or use a ForEach-Object loop." -f $uniqueTenants.Count, $pipedTypes.Count) + } +} + +if (-not (Test-InforcerSession)) { + Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' ` + -ErrorId 'NotConnected' -Category ConnectionError + return +} + +# 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 +# concurrent calls. The seed is initialised at module load (InforcerCommunity.psm1) so two +# parallel runspaces don't race on lazy-init. The defensive null-check below covers the case +# where a future refactor accidentally clears the seed mid-runtime. +if ($null -eq $script:InforcerProgressIdSeed) { $script:InforcerProgressIdSeed = 10000 } +$progressId = [System.Threading.Interlocked]::Increment([ref]$script:InforcerProgressIdSeed) + +# -Open only makes sense in default sync+save mode — nothing is saved to open when -NoWait +# (returns immediately) or -NoSave (polls but doesn't write files) is also set. +if ($Open.IsPresent -and ($NoWait.IsPresent -or $NoSave.IsPresent)) { + Write-Warning '-Open is ignored when -NoWait or -NoSave is set (no files are saved to launch).' +} + +# --- Validate and zip ReportType / OutputFormat --- +$reportTypes = @($ReportType | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +$outputFormats = @($OutputFormat | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + +if ($reportTypes.Count -eq 0) { + Write-Error -Message '-ReportType must contain at least one non-empty value.' ` + -ErrorId 'InvalidReportType' -Category InvalidArgument + return +} +if ($outputFormats.Count -eq 0) { + Write-Error -Message '-OutputFormat must contain at least one non-empty value.' ` + -ErrorId 'InvalidOutputFormat' -Category InvalidArgument + return +} + +$pairs = [System.Collections.Generic.List[PSObject]]::new() +if ($outputFormats.Count -eq 1) { + foreach ($rt in $reportTypes) { + [void]$pairs.Add([PSCustomObject]@{ ReportType = $rt; OutputFormat = $outputFormats[0] }) + } +} elseif ($outputFormats.Count -eq $reportTypes.Count) { + if ($reportTypes.Count -eq 1) { + Write-Error -Message "Cannot pair 1 ReportType with $($outputFormats.Count) OutputFormats. Specify one OutputFormat per ReportType, or repeat the type." ` + -ErrorId 'InvalidPairing' -Category InvalidArgument + return + } + for ($i = 0; $i -lt $reportTypes.Count; $i++) { + [void]$pairs.Add([PSCustomObject]@{ ReportType = $reportTypes[$i]; OutputFormat = $outputFormats[$i] }) + } +} else { + Write-Error -Message "Cannot pair $($reportTypes.Count) ReportType(s) with $($outputFormats.Count) OutputFormat(s). Provide 1 format (broadcast) or N formats (paired by index)." ` + -ErrorId 'InvalidPairing' -Category InvalidArgument + return +} + +# --- Resolve every pair through the schema validator --- +$entries = [System.Collections.Generic.List[hashtable]]::new() +$entryKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +foreach ($pair in $pairs) { + $resolverArgs = @{ + ReportType = $pair.ReportType + OutputFormat = $pair.OutputFormat + } + if ($PSBoundParameters.ContainsKey('Parameter')) { $resolverArgs['Parameter'] = $Parameter } + if ($PSBoundParameters.ContainsKey('ReportPeriod')) { $resolverArgs['ReportPeriod'] = $ReportPeriod } + if ($PSBoundParameters.ContainsKey('AssessmentId')) { $resolverArgs['AssessmentId'] = $AssessmentId } + if ($Collate.IsPresent) { $resolverArgs['Collate'] = $true } + + try { + $resolved = Resolve-InforcerReportTypeSchema @resolverArgs + } catch { + Write-Error -Message $_.Exception.Message -ErrorId 'SchemaValidationFailed' -Category InvalidArgument + return + } + + # Client-side dedup — drop exact (type, outputFormat) duplicates before POST (bug #1). + $dedupKey = ('{0}|{1}' -f $resolved.TypeKey.ToLowerInvariant(), $resolved.OutputFormat.ToLowerInvariant()) + if (-not $entryKeys.Add($dedupKey)) { + Write-Verbose "Skipping duplicate ($($resolved.TypeKey), $($resolved.OutputFormat)) — already in batch." + continue + } + + [void]$entries.Add([hashtable]$resolved.Entry) +} + +if ($entries.Count -eq 0) { + Write-Error -Message 'No valid report entries to submit.' -ErrorId 'NoEntries' -Category InvalidArgument + return +} + +# --- 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. +$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." ` + -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. +$tenantData = $null +$resolvedTenants = [System.Collections.Generic.List[int]]::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) + 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() + } else { + "Could not retrieve the tenant list to resolve '$unresolvableList'. Underlying error: $apiErr. $mixedHint".Trim() + } + Write-Error -Message $hint -ErrorId 'TenantLookupUnavailable' -Category PermissionDenied + return + } +} +foreach ($t in $TenantId) { + try { + $resolvedId = if ($tenantData) { + Resolve-InforcerTenantId -TenantId $t -TenantData $tenantData + } else { + Resolve-InforcerTenantId -TenantId $t + } + if ($null -ne $resolvedId) { [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 +} +$beforeDedup = $resolvedTenants.Count +$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)) +} + +if ($resolvedTenants.Count -eq 0) { + Write-Error -Message 'No tenants resolved.' -ErrorId 'NoTenants' -Category InvalidArgument + return +} + +# --- Validate -OutputPath (only when we actually intend to save) --- +$wantSave = -not $NoWait.IsPresent -and -not $NoSave.IsPresent +if ($wantSave) { + try { + # Refuse system paths (defense-in-depth against combining server-controlled filenames + # with attacker-suggested -OutputPath values like /etc or %WINDIR%). + $null = Test-InforcerSafeOutputPath -Path $OutputPath + if (-not (Test-Path -LiteralPath $OutputPath -PathType Container)) { + # Under -WhatIf, New-Item skips creation but doesn't throw — Resolve-Path would then + # blow up on the missing dir. Only resolve when the path actually exists post-create. + $null = New-Item -Path $OutputPath -ItemType Directory -Force -ErrorAction Stop + } + if (Test-Path -LiteralPath $OutputPath -PathType Container) { + $OutputPath = (Resolve-Path -LiteralPath $OutputPath).Path + } elseif (-not $WhatIfPreference) { + throw "Output directory '$OutputPath' was not created." + } + } catch { + Write-Error -Message "Cannot prepare output directory '$OutputPath': $($_.Exception.Message)" ` + -ErrorId 'OutputPathFailed' -Category InvalidArgument + return + } +} + +# --- Build POST body --- +$body = @{ + 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')) { + return +} + +Write-Verbose "POST /beta/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 +if ($null -eq $queueResponse) { + Write-Progress -Id $progressId -Activity 'Invoke-InforcerReport' -Completed + return +} + +# The POST response is an array of run records (one per run created). +$runs = @($queueResponse) + +# --- Branch on mode --- +if ($NoWait.IsPresent) { + # Close out the "Queuing..." progress bar before returning — otherwise it stays orphaned + # in the host until the next progress write in the runspace. + Write-Progress -Id $progressId -Activity 'Invoke-InforcerReport' -Completed + if ($OutputType -eq 'JsonObject') { + return ($runs | ConvertTo-Json -Depth 100) + } + foreach ($run in $runs) { + if ($run -is [PSObject]) { + $null = Add-InforcerPropertyAliases -InputObject $run -ObjectType ReportRun + if ($run.PSObject.TypeNames[0] -ne 'InforcerCommunity.ReportRun') { + $run.PSObject.TypeNames.Insert(0, 'InforcerCommunity.ReportRun') + } + } + } + return $runs +} + +# --- Sync mode: poll each run, optionally download --- +$results = [System.Collections.Generic.List[PSObject]]::new() +$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. + $runIdProp = $run.PSObject.Properties['runId'] + if (-not $runIdProp) { $runIdProp = $run.PSObject.Properties['id'] } + $runIdValue = if ($runIdProp) { $runIdProp.Value -as [string] } else { $null } + if (-not $runIdValue) { + Write-Warning "POST response missing 'runId' on a run record; skipping." + continue + } + $runIdGuid = [guid]::Empty + if (-not [guid]::TryParse($runIdValue, [ref]$runIdGuid)) { + Write-Warning "Run id '$runIdValue' is not a valid GUID; skipping." + continue + } + $completedRuns++ + $runProgressPct = [int](($completedRuns - 1) / [Math]::Max($totalRuns, 1) * 100) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $startedAt = Get-Date + $interval = [Math]::Max($PollIntervalSeconds, 1) + $outputsObj = $null + $timedOut = $false + $pollCount = 0 + while ($true) { + $pollCount++ + $elapsed = [int]((Get-Date) - $startedAt).TotalSeconds + # Push a fresh status on every iteration so the user sees the elapsed-time tick. + Write-Progress -Id $progressId -Activity 'Invoke-InforcerReport' ` + -Status ("Run {0}/{1}: polling {2} (elapsed {3}s, poll #{4})" -f $completedRuns, $totalRuns, $runIdValue, $elapsed, $pollCount) ` + -PercentComplete $runProgressPct + + try { + $probe = Test-InforcerReportRunTerminal -RunId $runIdGuid -ErrorAction Stop + } catch { + Write-Progress -Id $progressId -Activity 'Invoke-InforcerReport' -Completed + Write-Error -Message "Polling failed for run ${runIdValue}: $($_.Exception.Message)" ` + -ErrorId 'PollFailed' -Category ConnectionError + return + } + if ($probe.IsTerminal) { + $outputsObj = $probe.Outputs + break + } + if ((Get-Date) -ge $deadline) { $timedOut = $true; break } + Start-Sleep -Seconds $interval + $interval = [Math]::Min($interval * 2, 15) + } + + if ($timedOut) { + Write-Error -Message "Timed out waiting for run $runIdValue to complete (waited $TimeoutSeconds s). 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. + $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)." + } 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." + } + + $outputIndex = 0 + $outputTotal = $outputs.Count + foreach ($out in $outputs) { + if ($out -isnot [PSObject]) { continue } + $outId = $out.PSObject.Properties['id'].Value -as [string] + if (-not $outId) { + Write-Warning "Output record missing 'id' on run $runIdValue; skipping." + continue + } + $outputIndex++ + + if ($NoSave.IsPresent) { + # Inject the camelCase 'runId' before aliasing so the 'RunId' PSAliasProperty (added + # by Add-InforcerPropertyAliases for ObjectType=ReportOutput) resolves to it. Setting + # the underlying field rather than a separate NoteProperty keeps camel and Pascal in + # lockstep, matching the alias contract every other cmdlet follows. + if (-not $out.PSObject.Properties['runId']) { + $out | Add-Member -NotePropertyName 'runId' -NotePropertyValue $runIdValue -Force + } + $null = Add-InforcerPropertyAliases -InputObject $out -ObjectType ReportOutput + if ($out.PSObject.TypeNames[0] -ne 'InforcerCommunity.ReportOutput') { + $out.PSObject.TypeNames.Insert(0, 'InforcerCommunity.ReportOutput') + } + [void]$results.Add($out) + continue + } + + # Surface what's about to be downloaded as the progress status — file name + # isn't known yet (it comes from Content-Disposition), so use output id + type. + $outReportType = $out.PSObject.Properties['reportType'] + $outFormat = $out.PSObject.Properties['format'] + $outLabel = if ($outReportType -and $outFormat) { + "{0}.{1}" -f ($outReportType.Value -as [string]), ($outFormat.Value -as [string]) + } else { $outId } + Write-Progress -Id $progressId -Activity 'Invoke-InforcerReport' ` + -Status ("Run {0}/{1}: downloading output {2}/{3} ({4})" -f $completedRuns, $totalRuns, $outputIndex, $outputTotal, $outLabel) ` + -PercentComplete $runProgressPct + + # Download and save. Output record shape (confirmed): + # { 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" + $defaultName = ('{0}-{1}' -f $runIdValue, $outId) + try { + $download = Invoke-InforcerRawDownload -Endpoint $downloadEndpoint -DefaultFileName $defaultName ` + -DestinationDirectory $OutputPath -ErrorAction Stop + } catch { + Write-Error -Message "Failed to download output $outId for run ${runIdValue}: $($_.Exception.Message)" ` + -ErrorId 'DownloadFailed' -Category ReadError + continue + } + if ($null -eq $download) { continue } + + $filePath = $download.FilePath + $fileSize = if ($download.PSObject.Properties['FileSize']) { $download.FileSize } else { (Get-Item -LiteralPath $filePath -ErrorAction SilentlyContinue).Length } + + $tenantIdValue = $out.PSObject.Properties['tenantId'] + $reportTypeValue = $out.PSObject.Properties['reportType'] + $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) { + 'Collated' + } else { + $rawTenantId + } + + $result = [PSCustomObject][ordered]@{ + RunId = $runIdValue + OutputId = $outId + TenantId = $tenantOut + ReportType = if ($reportTypeValue) { $reportTypeValue.Value -as [string] } else { $null } + OutputFormat = if ($formatValue) { $formatValue.Value -as [string] } else { $null } + FileName = $download.FileName + FilePath = $filePath + FileSize = $fileSize + ContentType = $download.ContentType + CorrelationId = $download.CorrelationId + } + $result.PSObject.TypeNames.Insert(0, 'InforcerCommunity.ReportRunResult') + [void]$results.Add($result) + } +} + +Write-Progress -Id $progressId -Activity 'Invoke-InforcerReport' -Completed + +# -Open: launch saved files with the OS default handler. Done AFTER the loop so we can +# decide between launching each file (small N) and opening the parent directory (large N). +# Failure to launch is non-fatal — the files are already saved. +if ($Open.IsPresent -and $results.Count -gt 0) { + # SECURITY: only auto-launch files with extensions on the allowlist. The Reports API + # legitimately returns CSV/JSON/HTML/PDF/XLSX/XML/TXT/ZIP; anything else (.command, + # .app, .lnk, .desktop, .url, .scpt, .sh, .bat, .exe, .vbs, .ps1, .jar, .terminal, + # .workflow, …) could let a compromised or malicious upstream auto-execute attacker + # code via the OS file-type association. For non-allowlisted files, open the directory + # instead so the user can choose what to do with them. + $openAllowlist = @('.csv','.json','.html','.htm','.pdf','.xlsx','.xls','.xml','.txt','.zip','.md','.tsv') + $openMax = 5 + $unsafe = @($results | Where-Object { + $ext = ([System.IO.Path]::GetExtension($_.FilePath) -as [string]).ToLowerInvariant() + $ext -notin $openAllowlist + }) + if ($unsafe.Count -gt 0) { + $unsafeExts = ($unsafe | ForEach-Object { [System.IO.Path]::GetExtension($_.FilePath) } | Sort-Object -Unique) -join ', ' + Write-Warning ("Skipping auto-launch for {0} file(s) with non-allowlisted extension(s) ({1}). Opening the output directory instead — review the files before launching them yourself." -f $unsafe.Count, $unsafeExts) + try { + Invoke-Item -LiteralPath $OutputPath -ErrorAction Stop + Write-Verbose "Opened directory '$OutputPath' with the default handler." + } catch { + Write-Warning "Could not open '$OutputPath': $($_.Exception.Message)" + } + } elseif ($results.Count -gt $openMax) { + Write-Warning ("Saved {0} files; opening the output directory instead of every file (-Open caps at {1} simultaneous launches)." -f $results.Count, $openMax) + try { + Invoke-Item -LiteralPath $OutputPath -ErrorAction Stop + Write-Verbose "Opened directory '$OutputPath' with the default handler." + } catch { + Write-Warning "Could not open '$OutputPath': $($_.Exception.Message)" + } + } else { + foreach ($r in $results) { + try { + Invoke-Item -LiteralPath $r.FilePath -ErrorAction Stop + Write-Verbose "Opened '$($r.FilePath)' with the default handler." + } catch { + Write-Warning "Could not open '$($r.FilePath)': $($_.Exception.Message)" + } + } + } +} + +if ($OutputType -eq 'JsonObject') { + return (,@($results) | ConvertTo-Json -Depth 100) +} +$results + +} # end of end{} block +} diff --git a/module/Public/Save-InforcerReportOutput.ps1 b/module/Public/Save-InforcerReportOutput.ps1 new file mode 100644 index 0000000..e05cf74 --- /dev/null +++ b/module/Public/Save-InforcerReportOutput.ps1 @@ -0,0 +1,196 @@ +<# +.SYNOPSIS + Downloads a report output to disk. + + Required API scope(s): Reports.Read +.DESCRIPTION + GET /beta/reports/runs/{runId}/outputs/{outputId} 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. + + Pipeline-friendly: pipe output records from Invoke-InforcerReport -NoSave or + Get-InforcerReportRun -IncludeOutputs and every output is downloaded. +.PARAMETER RunId + The run identifier (GUID). Pipeline-bindable by property name. +.PARAMETER OutputId + The output identifier (string) — the id field on each output record. Pipeline-bindable + by property name (also accepts -Id as an alias). +.PARAMETER ReportType + Pipeline-bindable. When piped from Invoke-InforcerReport -NoSave or Get-InforcerReportRun + -IncludeOutputs, this is auto-populated from the upstream record and surfaced on the + result object so output formatting matches Invoke-InforcerReport. Otherwise $null. +.PARAMETER OutputFormat + 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. +.PARAMETER OutputPath + Directory where downloaded outputs are written. Defaults to the current working directory. + Created if it doesn't exist. +.PARAMETER FileName + Override the server-suggested filename. Sanitized for cross-platform safety. +.PARAMETER OutputType + PowerShellObject (default) or JsonObject. +.EXAMPLE + Save-InforcerReportOutput -RunId -OutputId + Saves a single output to the current directory. +.EXAMPLE + Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 482 -NoSave | + Save-InforcerReportOutput -OutputPath ./reports + Queues + polls a report without saving, then downloads every output to ./reports. +.EXAMPLE + Get-InforcerReportRun -IncludeOutputs | + ForEach-Object { $_.outputs } | + Save-InforcerReportOutput -OutputPath ./bulk + Bulk-downloads every output from every visible run. +.OUTPUTS + PSObject or String — per saved file, with { RunId, OutputId, TenantId, ReportType, + OutputFormat, FilePath, FileName, FileSize, ContentType, CorrelationId }. TenantId, + ReportType, and OutputFormat are $null unless populated via pipeline binding. +.LINK + https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#save-inforcerreportoutput +.LINK + Invoke-InforcerReport +.LINK + Get-InforcerReportRun +#> +function Save-InforcerReportOutput { +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] +[OutputType([PSObject], [string])] +param( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 0)] + [guid]$RunId, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 1)] + [Alias('Id')] + [string]$OutputId, + + # Pipeline pass-through properties from upstream cmdlets (Invoke-InforcerReport -NoSave, + # Get-InforcerReportRun -IncludeOutputs). Surfaced on the result so it matches the + # InforcerCommunity.ReportRunResult format view; left $null when called standalone. + [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] + [string]$ReportType, + + [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] + [string]$OutputFormat, + + [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] + [Alias('ClientTenantId')] + [object]$TenantId, + + [Parameter(Mandatory = $false)] + [string]$OutputPath = $PWD.Path, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$FileName, + + [Parameter(Mandatory = $false)] + [ValidateSet('PowerShellObject', 'JsonObject')] + [string]$OutputType = 'PowerShellObject' +) + +begin { + # PowerShell quirk: `return` inside `begin` does NOT prevent `process` from firing for + # piped items. Gate `process` on a "begin succeeded" flag instead. + # Locals (NOT $script:*) so two parallel invocations sharing one module import don't + # clobber each other's OutputPath. + $beginOk = $false + $resolvedOutputPath = $null + + if (-not (Test-InforcerSession)) { + Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' ` + -ErrorId 'NotConnected' -Category ConnectionError + return + } + try { + # Defense-in-depth: refuse system paths. + $null = Test-InforcerSafeOutputPath -Path $OutputPath + if (-not (Test-Path -LiteralPath $OutputPath -PathType Container)) { + $null = New-Item -Path $OutputPath -ItemType Directory -Force -ErrorAction Stop + } + if (Test-Path -LiteralPath $OutputPath -PathType Container) { + $resolvedOutputPath = (Resolve-Path -LiteralPath $OutputPath).Path + } elseif (-not $WhatIfPreference) { + throw "Output directory '$OutputPath' was not created." + } + } catch { + Write-Error -Message "Cannot prepare output directory '$OutputPath': $($_.Exception.Message)" ` + -ErrorId 'OutputPathFailed' -Category InvalidArgument + return + } + $beginOk = $true +} + +process { + if (-not $beginOk) { return } + if ([string]::IsNullOrWhiteSpace($OutputId)) { + Write-Error -Message 'OutputId is empty.' -ErrorId 'InvalidOutputId' -Category InvalidArgument + return + } + + $runIdStr = $RunId.ToString() + $endpoint = "/beta/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). + # -FileName has ValidateNotNullOrEmpty so we won't get '' here, but still guard against whitespace. + $userSuppliedName = $PSBoundParameters.ContainsKey('FileName') -and -not [string]::IsNullOrWhiteSpace($FileName) + $defaultName = if ($userSuppliedName) { $FileName } else { ('{0}-{1}' -f $runIdStr, $OutputId) } + + $target = "$runIdStr / $OutputId → $resolvedOutputPath" + if (-not $PSCmdlet.ShouldProcess($target, 'Download report output')) { return } + + Write-Verbose "Downloading: $endpoint" + try { + # Stream straight to disk so multi-GB report outputs don't materialize in memory. + # The helper renames the temp file to the Content-Disposition filename for us. + $download = Invoke-InforcerRawDownload -Endpoint $endpoint -DefaultFileName $defaultName ` + -DestinationDirectory $resolvedOutputPath -ErrorAction Stop + } catch { + Write-Error -Message "Failed to download output ${OutputId}: $($_.Exception.Message)" ` + -ErrorId 'DownloadFailed' -Category ReadError + return + } + if ($null -eq $download) { return } + + $filePath = $download.FilePath + $effectiveName = $download.FileName + + # When the user supplied an explicit -FileName, rename from the server-derived name to theirs. + if ($userSuppliedName) { + $overrideName = Resolve-InforcerReportOutputFileName -ContentDisposition $null -DefaultName $FileName + $overridePath = Join-Path -Path $resolvedOutputPath -ChildPath $overrideName + if ($overridePath -ne $filePath) { + try { + Move-Item -LiteralPath $filePath -Destination $overridePath -Force -ErrorAction Stop + $filePath = $overridePath + $effectiveName = $overrideName + } catch { + Write-Warning "Could not rename to user-requested '$overrideName': $($_.Exception.Message)" + } + } + } + + $fileSize = if ($download.PSObject.Properties['FileSize']) { $download.FileSize } else { (Get-Item -LiteralPath $filePath -ErrorAction SilentlyContinue).Length } + + $result = [PSCustomObject][ordered]@{ + RunId = $runIdStr + OutputId = $OutputId + TenantId = if ($PSBoundParameters.ContainsKey('TenantId')) { $TenantId } else { $null } + ReportType = if ($PSBoundParameters.ContainsKey('ReportType')) { $ReportType } else { $null } + OutputFormat = if ($PSBoundParameters.ContainsKey('OutputFormat')) { $OutputFormat } else { $null } + FilePath = $filePath + FileName = $effectiveName + FileSize = $fileSize + ContentType = $download.ContentType + CorrelationId = $download.CorrelationId + } + $result.PSObject.TypeNames.Insert(0, 'InforcerCommunity.ReportRunResult') + + if ($OutputType -eq 'JsonObject') { + $result | ConvertTo-Json -Depth 100 + } else { + $result + } +} +} diff --git a/module/README.md b/module/README.md index d1cba52..1afc09c 100644 --- a/module/README.md +++ b/module/README.md @@ -1,6 +1,6 @@ # InforcerCommunity PowerShell Script Module -This is the script implementation of the InforcerCommunity module (community project for the Inforcer API). It provides cmdlets to connect to the Inforcer API and query tenants, baselines, policies, alignment scores, and audit events. +This is the script implementation of the InforcerCommunity module (community project for the Inforcer API). It provides cmdlets to connect to the Inforcer API and work with tenants, baselines, policies, alignment scores, audit events, users, groups, roles, assessments, and reports — plus utility cmdlets for documentation export and cross-tenant comparison. ## Module structure @@ -11,7 +11,7 @@ module/ ├── InforcerCommunity.Format.ps1xml # Default table/list formats ├── InforcerCommunity.Types.ps1xml # Type definitions ├── README.md # This file -├── Public/ # Exported cmdlets +├── Public/ # Exported cmdlets (20) │ ├── Connect-Inforcer.ps1 │ ├── Disconnect-Inforcer.ps1 │ ├── Test-InforcerConnection.ps1 @@ -21,18 +21,23 @@ module/ │ ├── Get-InforcerAlignmentDetails.ps1 │ ├── Get-InforcerAuditEvent.ps1 │ ├── Get-InforcerSupportedEventType.ps1 -│ └── Get-InforcerUser.ps1 +│ ├── Get-InforcerUser.ps1 +│ ├── Get-InforcerGroup.ps1 +│ ├── Get-InforcerRole.ps1 +│ ├── Export-InforcerTenantDocumentation.ps1 +│ ├── Compare-InforcerEnvironments.ps1 +│ ├── Get-InforcerAssessment.ps1 +│ ├── Invoke-InforcerAssessment.ps1 +│ ├── Get-InforcerReportType.ps1 +│ ├── Invoke-InforcerReport.ps1 +│ ├── Get-InforcerReportRun.ps1 +│ └── Save-InforcerReportOutput.ps1 └── Private/ # Helpers (not exported) - ├── Invoke-InforcerApiRequest.ps1 - ├── Test-InforcerSession.ps1 - ├── Get-InforcerBaseUrl.ps1 - ├── Resolve-InforcerTenantId.ps1 - ├── Add-InforcerPropertyAliases.ps1 - ├── Filter-InforcerResponse.ps1 - ├── ConvertFrom-InforcerSecureString.ps1 - └── ConvertTo-InforcerArray.ps1 + └── ... (see directory listing) ``` +For the full list of cmdlets with descriptions, see the repository root [`README.md`](../README.md). For parameter-level docs, see [`docs/CMDLET-REFERENCE.md`](../docs/CMDLET-REFERENCE.md). For endpoint and schema docs, see [`docs/API-REFERENCE.md`](../docs/API-REFERENCE.md). + ## Loading the script module Run from the repository root so the path resolves to this repo's module folder. diff --git a/scripts/Test-AllCmdlets.ps1 b/scripts/Test-AllCmdlets.ps1 index 0cb3286..b131809 100644 --- a/scripts/Test-AllCmdlets.ps1 +++ b/scripts/Test-AllCmdlets.ps1 @@ -72,6 +72,18 @@ foreach ($name in $exported) { 'Invoke-InforcerAssessment' { $out = & $name -TenantId 1 -AssessmentId 'test' -ErrorVariable err -ErrorAction SilentlyContinue } + 'Get-InforcerReportType' { + $out = & $name -ErrorVariable err -ErrorAction SilentlyContinue + } + 'Invoke-InforcerReport' { + $out = & $name -ReportType 'ActiveUserCount' -OutputFormat csv -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue + } + 'Get-InforcerReportRun' { + $out = & $name -ErrorVariable err -ErrorAction SilentlyContinue + } + 'Save-InforcerReportOutput' { + $out = & $name -RunId ([guid]::NewGuid()) -OutputId 'out-1' -ErrorVariable err -ErrorAction SilentlyContinue + } default { $out = & $name -ErrorVariable err -ErrorAction SilentlyContinue }