Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
18680ed
fix: Invoke-InforcerAssessment could never reach the API
roy-klooster-inforcer Aug 24, 2026
24e84bc
fix: one-sided baseline scoping reported a meaningless alignment score
roy-klooster-inforcer Aug 24, 2026
769669c
feat!: writing files and opening a browser are both opt-in
roy-klooster-inforcer Aug 24, 2026
ece1f4a
docs: release 0.7.0 — changelog, cmdlet reference, versioning note
roy-klooster-inforcer Aug 24, 2026
19bec67
refactor: delete 229 no-op property-alias calls
roy-klooster-inforcer Aug 24, 2026
8ee3f0b
fix: -FetchGraphData no longer installs a module without asking
roy-klooster-inforcer Aug 24, 2026
95e0300
chore: gitignore the API feedback file and its evidence
royklo Aug 26, 2026
143a1fb
fix: Markdown export named the wrong baseline in its header
royklo Sep 4, 2026
a8dc1c5
chore: gitignore Pester's testResults.xml
royklo Sep 4, 2026
6e3374c
fix: Connect-Inforcer reported Connected for an expired API key
royklo Sep 7, 2026
1529834
feat: Get-InforcerGroup shows MembershipRule and OnPremisesSyncEnabled
royklo Sep 9, 2026
cb77b1c
test: guard the 0.7.0 opt-in output contract
royklo Sep 9, 2026
321507e
feat: auto-open the HTML report again, except on CI
royklo Sep 9, 2026
9b778d7
docs: bring the 0.7.0 release notes and docs in line with what ships
royklo Sep 9, 2026
e1e7d41
docs: drop the Refactor and Tests sections from the 0.7.0 entry
royklo Sep 9, 2026
74aef87
test: cover the three 0.7.0 fixes that only live runs reached
royklo Sep 9, 2026
f99cf58
docs: document Get-InforcerAuditEvent -Id and -CorrelationId
royklo Sep 9, 2026
86861de
docs: repair two truncated -Show comments
royklo Sep 9, 2026
c440a51
Merge branch 'main' into release/v0.7.0
royklo Sep 9, 2026
2662bfc
fix(test): CI-detection tests failed on CI, because they ran on CI
royklo Sep 9, 2026
1a6c7db
docs: note that the upstream schema now contradicts the runtime on me…
royklo Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,15 @@ scripts/Test-ApiFeedbackItems.ps1

# Local API key for live-* scripts (never commit)
.inforcer-key.local

# API feedback for the Inforcer API team, and the raw captures behind it.
# Untracked on purpose: the evidence carries tenant ids, policy names and live
# response bodies from real customer tenants.
api-feedback.md
Reports-API-Feedback.md
api-feedback-evidence/

# Pester's default TestResult output path, produced by Invoke-Pester -CI. Nothing
# reads it: build-and-test.yml takes the result from -PassThru in memory and there
# is no test-result publisher in the pipeline.
testResults.xml
47 changes: 46 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,52 @@

All notable changes to this project will be documented in this file.

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.
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; docs/tests/chore-only commits don't bump. While the module is pre-1.0 a breaking change also bumps MINOR and is called out under a **Breaking Changes** heading — 1.0.0 is reserved for the point the public surface is declared stable, after which breaking changes bump MAJOR. There is intentionally no `[Unreleased]` section — every entry is dated at ship time.

## [0.7.0] - 2026-09-09

### Breaking Changes

- **`Compare-InforcerEnvironments` and `Export-InforcerTenantDocumentation` no longer write files by default.** Both had `$OutputPath = '.'` plus an unconditional `Start-Process` on the rendered HTML, so neither could run without dropping a file into the caller's working directory and spawning a browser window. In a CI pipeline or a container that is wrong twice over: the file is litter, and there is nothing to open it with.
- Writing now happens only when `-OutputPath` is given. There is no default. `-OutputPath`'s presence *is* the opt-in — a separate `-Export` switch would carry no information the path does not already carry.
- **Opening a browser is unchanged for interactive use.** The new `-Show` switch defaults to on at a prompt and off on a build agent (`CI`, `TF_BUILD`, `GITHUB_ACTIONS`, `GITLAB_CI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `BUILDKITE`), via `Test-InforcerInteractiveHost`. Passing `-Show` or `-Show:$false` always beats the detection. The two halves of this change had different justifications and only one of them survived scrutiny: a default `-OutputPath` writes a file nobody asked for, but opening a report you *did* ask for by passing `-OutputPath` is the continuation of an explicit request, not a surprise. The original driver for suppressing it — the MCP server invoking the cmdlet and spawning a browser — no longer exists: the MCP reads the REST API directly and never shells out to PowerShell (`src/manifest.ts`: "a pointer, not an integration: the repos never call each other").
- **Without `-OutputPath` the cmdlets return the model instead of `System.IO.FileInfo`** — the comparison hashtable from `Compare-InforcerEnvironments`, the DocModel hashtable from `Export-InforcerTenantDocumentation`. This makes it possible to read alignment scores or tenant configuration without producing artefacts.
- **Migration:** add `-OutputPath <dir>` to any call that relied on files appearing in the working directory. Scripts consuming the `FileInfo` return value need `-OutputPath` to keep that return type. Nothing is needed for the browser: interactive callers keep the old behaviour, and CI callers that never wanted it now get a file only. Add `-Show:$false` to an interactive script that writes many reports in a loop and does not want a window per tenant.

### Features

- **HTML reports open automatically again in an interactive session — and never on a CI runner.** `Compare-InforcerEnvironments` and `Export-InforcerTenantDocumentation` gained `-Show`, which defaults to `(Test-InforcerInteractiveHost)`: true at a prompt, false when `CI`, `TF_BUILD`, `GITHUB_ACTIONS`, `GITLAB_CI`, `JENKINS_URL`, `TEAMCITY_VERSION` or `BUILDKITE` is set. `-Show` forces it and `-Show:$false` suppresses it; an explicit value always beats the detection, so a script writing one report per tenant in a loop opts out with a single flag. The detection is deliberately conservative — it answers "is this definitely not a build agent", not "is there definitely a browser", because `[Environment]::UserInteractive` is true for most non-Windows PowerShell sessions including scripted ones, leaving the CI variables to carry the weight. See **Breaking Changes** for why only half of the original opt-in change survived.

- **`Get-InforcerGroup -Group` now shows `MembershipRule` and `OnPremisesSyncEnabled` by default.** Both were returned by the by-ID endpoint and dropped by the `Format.ps1xml` view, so the property that says what a dynamic group actually *does*, and the one that says whether a group is even editable in the cloud, were invisible unless you knew to ask for `Select-Object *`. `MembershipRule` is guarded by an `ItemSelectionCondition` and appears only when populated, so static groups render exactly as before instead of gaining a permanently blank line. `OnPremisesSyncEnabled` is always shown and renders the API's three states distinctly — `True`, `False (cloud-only)` for `null`, `False (no longer syncing)` for an explicit `false` — because a bare blank cannot distinguish "never synced" from "de-synced", and the two mean different things when you are deciding where a group can be changed.

**`MembershipRule` is not obtainable from the group *list*.** `GET /beta/tenants/{id}/groups` emits the `membershipRule` key but leaves it `null` on every group, `DynamicMembership` ones included — verified against a live tenant, where `-Group` on the same group ID returns `(user.userType -eq "Member")` and the list returns `null`. `docs/API-REFERENCE.md` claimed the opposite (added in this release's Documentation section, from the #39 OpenAPI drift snapshot) and has been corrected; `onPremisesSyncEnabled` is absent from the list payload entirely. The `GroupSummary` view carries the same conditional row so it lights up with no code change if the API starts filling it, but today it renders nothing — reading rules across a tenant costs one call per dynamic group. Filed as item 26 in the API feedback document.

### Bug Fixes

- **`Invoke-InforcerAssessment` could never reach the API.** Every call failed with `400 ValidationFailure — "Unspecified content type application/json is not allowed."` `POST /beta/tenants/{id}/assessments/{assessmentId}/runs` accepts no request body and rejects *any* `Content-Type`. Removing the header from the hashtable was not enough: `Invoke-RestMethod` supplies `application/x-www-form-urlencoded` on a bodyless POST, which is rejected the same way. Now sends `-ContentType ''`, which suppresses it entirely.
- **`Invoke-InforcerAssessment -MultiTenant` crashed instead of reporting when every tenant failed.** The run loop fell through to renderers whose `-TenantResults` is mandatory, producing `Cannot bind argument to parameter 'TenantResults' because it is an empty collection` — a parameter-binding error that said nothing about why the runs failed. A single guard after the loop now covers the JSON, HTML and CSV paths and emits `NoAssessmentResults`.
- **`Compare-InforcerEnvironments -SourceBaselineId` reported a meaningless alignment score.** Scoping the source left the destination at its full policy set, so N baseline policies were compared against the destination's entire estate and every destination-only policy counted as a deviation: a 3-policy baseline against a 756-policy tenant scored **0.2%** where Inforcer's own alignment for the same pair is 100%. The docstring advertised exactly that one-sided form as an example. The destination now inherits `-SourceBaselineId` unless `-DestinationBaselineId` overrides it (same pair now scores 100% over 3 items). When the destination genuinely is not a member of the baseline it falls back to its full policy set with a warning naming the consequence, rather than erroring; an explicitly passed `-DestinationBaselineId` that fails is still an error.
- **`Compare-InforcerEnvironments -ExcludeOS` was a no-op for every value its own documentation gave as an example.** It matched only the product name (`Entra`, `Intune`, `Defender`, …), while the OS lives in the category key built from `primaryGroup` (`Windows`, `macOS`, `iOS/iPadOS`, `Android`). `-ExcludeOS 'macOS','iOS'` removed 0 of 2229 items while reporting success. It now matches the category key as well as the product name, so `-ExcludeOS 'macOS','iOS'` removes 720 items and passing a product name still works.
- **`-FetchGraphData` silently installed a module onto the machine.** `Connect-InforcerGraph` ran `Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force -AllowClobber` with no consent when the module was absent. `-Force` suppresses the untrusted-repository prompt and `-AllowClobber` permits overwriting commands owned by other modules — neither is appropriate for a read-only reporting module, and on a locked-down or offline host it failed with a PowerShellGet error rather than saying what was missing. It now detects and reports, naming the install command, matching how the `ImportExcel` dependency is already handled. A **warning**, not an error: every caller already falls back to raw ObjectIDs on a null Graph context, and `Write-Error` would have terminated that graceful path under `$ErrorActionPreference = 'Stop'`.

- **`Connect-Inforcer` reported `Connected` for an expired API key.** The validation probe interprets a 4xx carrying the Inforcer app envelope (`success`/`errorCode`/`errors`) as "subscription is live, this key just lacks the scope for `/beta/baselines`" — deliberately, so a key holding only `Reports.Read` can still connect. An expired key returns that *same* envelope, down to `errorCode: "forbidden"` — `403` + `{"data":null,"errorCode":"forbidden","success":false,"message":"A valid API key is required to access this endpoint.","errors":["API key has expired."]}` — so it passed validation and every subsequent cmdlet failed with a 403 the session had just promised would not happen. Neither the status code, the envelope shape nor `errorCode` separates the two cases, and the API exposes no key-introspection endpoint (no `/beta/me`, no scope list) — so `errors[]` is the only signal available. The probe now reads it and refuses to connect when it names a key-lifecycle problem (expired, revoked, deactivated, disabled). It reads `errors[]` *only*, never the top-level `message`: the API serves that same generic `a valid API key is required…` sentence for plain scope denials, and matching it would lock out every narrow-scope key — the one thing this validation must never do, since a key holding only `Reports.Read` is a legitimate caller.

Connecting on a denied probe is still correct, but it now says so instead of hiding it under `-Verbose`: a key whose probe returned 403 gets a warning that the subscription is live and the key is not expired, but its scopes are unverified, so a later 403 is a scope gap rather than a bad session. Filed with the API team as item 25 in the feedback document, with the fix that would remove the string matching: a distinct `errorCode` for key-level failures, or `401` for an invalid credential versus `403` for an unpermitted one, or a `GET /beta/me` returning `{isActive, expiresAt, scopes[]}`.

- **`Export-InforcerTenantDocumentation -Tag` rendered an empty document when nothing matched.** A 0.1 KB file and exit 0 reads as "nothing in this tenant is tagged that way" when the likelier cause is a tag name that does not exist. It now emits `TagMatchedNothing` and names the tags that do exist, writing no file.

- **`Export-InforcerTenantDocumentation -Format Markdown` named the wrong baseline in the header.** The Markdown header printed `DocModel.BaselineName`, which `ConvertTo-InforcerDocModel` fills with the *first baseline attached to the tenant* — unrelated to `-Baseline`. Exporting the Blueprint Library filtered to `Tier 2 - Enhanced` produced a correctly filtered 238-policy document headed `*Baseline: … Tier 0 - Initiate*`, and an unfiltered export named a baseline it was not scoped to at all. The header now reads `FilterBaseline`, the same field the HTML renderer already used, so it names the baseline actually filtered on and is omitted when there is none. `-Tag` is now shown in the Markdown header too, matching HTML.

### Documentation

- `-OutputPath`, the new `-Show` switch, and the changed return types documented in `Get-Help` and `docs/CMDLET-REFERENCE.md` for both affected cmdlets. The **Output** sections of both entries in `docs/CMDLET-REFERENCE.md` still promised a `FileInfo` return and an auto-opening browser — the parameter tables had been corrected but the return-type prose had not.
- `-ExcludeOS` help now states that matching applies to both product names and platform category keys, in `docs/CMDLET-REFERENCE.md` as well as `Get-Help`.
- `membershipRule` added to the **TenantGroupSummary** schema in `docs/API-REFERENCE.md`, picked up from the OpenAPI drift snapshot in #39, which the nightly workflow updates without touching the hand-written reference. The entry originally read "the API now returns it on the group *list* endpoint as well as by-ID" — the schema says so, the runtime does not: the key is emitted and always `null`. Corrected to say exactly that, with the by-ID endpoint named as the only source. A schema gaining a property is not evidence the property is populated; this one was documented off the snapshot without a live check.
- `onPremisesSyncEnabled` in the **TenantGroup** schema now documents all three states (`true` / `false` / `null`) rather than "whether synced from on-premises AD", which read as a plain boolean and gave `null` no meaning.
- `-SourceBaselineId` help documents destination inheritance and the non-member fallback; the stale example claiming a one-sided comparison against "all Fabrikam policies" corrected.
- Versioning note above clarified: pre-1.0, breaking changes bump MINOR under a **Breaking Changes** heading rather than MAJOR.
- `-Show`'s interactive/CI default documented in `Get-Help` and the `docs/CMDLET-REFERENCE.md` parameter tables for both cmdlets, and the **Output** sections of both entries corrected — they still said the report "opens in the default browser only when `-Show` is passed", which stopped being true when the default changed.
- `Get-InforcerGroup` example output in `docs/CMDLET-REFERENCE.md` now shows a real dynamic group with its `MembershipRule` and the `OnPremisesSyncEnabled` three-state table, plus the per-group call needed to read rules in bulk.

## [0.6.0] - 2026-07-06

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,14 @@ Get-InforcerTenantPolicies -TenantId 482
# Show policy changes (PolicyDiff on each tenant when available)
Get-InforcerTenant | Select-Object ClientTenantId, TenantFriendlyName, PolicyDiff

# Generate tenant documentation as HTML
# Generate tenant documentation as HTML. -OutputPath is what writes the file: omit it and the
# DocModel is returned instead, so you can read the configuration without producing artefacts.
# The report opens in your browser automatically, unless you are on a CI runner or pass -Show:$false.
Export-InforcerTenantDocumentation -Format Html -TenantId 482 -OutputPath ./docs

# Generate documentation for a specific baseline with Graph group name resolution
Connect-Inforcer -ApiKey "your-api-key" -Region uk -FetchGraphData
Export-InforcerTenantDocumentation -Format Html -TenantId 482 -Baseline "Production" -FetchGraphData
Export-InforcerTenantDocumentation -Format Html -TenantId 482 -Baseline "Production" -FetchGraphData -OutputPath ./docs

# Disconnect when done
Disconnect-Inforcer
Expand All @@ -111,8 +113,8 @@ Disconnect-Inforcer
| **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. |
| **Get-InforcerSecureScore** | Retrieves the current and 90-day historic Microsoft Secure Score for a tenant, including per-category scores and actionable control profiles. |
| **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. |
| **Export-InforcerTenantDocumentation** | Generates comprehensive tenant documentation in HTML, Markdown, or Excel format. Pass `-OutputPath` to write files; without it the DocModel is returned and nothing is written. |
| **Compare-InforcerEnvironments** | Compares two tenants' Intune configuration and generates an interactive HTML comparison report. Pass `-OutputPath` to write the file; without it the comparison model is returned and nothing is written. 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. |
Expand Down
Loading